Dave Jarvis' Repositories

M src/main/java/com/keenwrite/Bootstrap.java
22
package com.keenwrite;
33
4
import java.io.InputStream;
5
import java.util.Calendar;
46
import java.util.Properties;
57
...
1517
 */
1618
public final class Bootstrap {
17
  private static final Properties BOOTSTRAP = new Properties();
19
  /**
20
   * Order matters, this must be populated before deriving the app title.
21
   */
22
  private static final Properties P = new Properties();
1823
1924
  static {
20
    try( final var stream =
21
             Constants.class.getResourceAsStream( "/bootstrap.properties" ) ) {
22
      BOOTSTRAP.load( stream );
25
    try( final var in = openResource( "/bootstrap.properties" ) ) {
26
      P.load( in );
2327
    } catch( final Exception ignored ) {
2428
      // Bootstrap properties cannot be found, throw in the towel.
2529
    }
2630
  }
2731
28
  public static final String APP_TITLE =
29
      BOOTSTRAP.getProperty( "application.title" );
32
  public static final String APP_TITLE = P.getProperty( "application.title" );
3033
  public static final String APP_TITLE_LOWERCASE = APP_TITLE.toLowerCase();
34
  public static final String APP_VERSION = Launcher.getVersion();
35
  public static final String APP_YEAR = getYear();
36
37
  @SuppressWarnings( "SameParameterValue" )
38
  private static InputStream openResource( final String path ) {
39
    return Constants.class.getResourceAsStream( path );
40
  }
41
42
  private static String getYear() {
43
    return Integer.toString( Calendar.getInstance().get( Calendar.YEAR ) );
44
  }
3145
}
3246
M src/main/java/com/keenwrite/Constants.java
5050
  public static final String APP_BUNDLE_NAME = get( "application.messages" );
5151
52
  /**
53
   * Prevent double events when updating files on Linux (save and timestamp).
54
   */
55
  public static final int APP_WATCHDOG_TIMEOUT = get(
56
    "application.watchdog.timeout", 200 );
57
58
  public static final String STYLESHEET_MARKDOWN = get(
59
    "file.stylesheet.markdown" );
52
  public static final String STYLESHEET_APPLICATION_BASE =
53
    get( "file.stylesheet.application.base" );
54
  public static final String STYLESHEET_APPLICATION_THEME =
55
    get( "file.stylesheet.application.theme" );
56
  public static final String STYLESHEET_MARKDOWN =
57
    get( "file.stylesheet.markdown" );
6058
  public static final String STYLESHEET_MARKDOWN_LOCALE =
6159
    "file.stylesheet.markdown.locale";
62
  public static final String STYLESHEET_PREVIEW = get(
63
    "file.stylesheet.preview" );
60
  public static final String STYLESHEET_PREVIEW =
61
    get( "file.stylesheet.preview" );
6462
  public static final String STYLESHEET_PREVIEW_LOCALE =
6563
    "file.stylesheet.preview.locale";
66
  public static final String STYLESHEET_SCENE = get( "file.stylesheet.scene" );
6764
6865
  public static final List<Image> LOGOS = createImages(
...
191188
   * Default monospace preview font name.
192189
   */
193
  public static final String FONT_NAME_PREVIEW_MONO_NAME_DEFAULT = "Source Code Pro";
190
  public static final String FONT_NAME_PREVIEW_MONO_NAME_DEFAULT =
191
    "Source Code Pro";
194192
195193
  /**
...
202200
   */
203201
  public static final Locale LOCALE_DEFAULT = withScript( Locale.getDefault() );
202
203
  /**
204
   * Default CSS theme to apply (resolves to a minimal implementation).
205
   */
206
  public static final String THEME_DEFAULT = "Modena Light";
207
208
  /**
209
   * Custom CSS theme to apply.
210
   */
211
  public static final File THEME_CUSTOM_DEFAULT = null;
204212
205213
  /**
...
216224
  private static String get( final String key ) {
217225
    return sSettings.getSetting( key, "" );
218
  }
219
220
  @SuppressWarnings( "SameParameterValue" )
221
  private static int get( final String key, final int defaultValue ) {
222
    return sSettings.getSetting( key, defaultValue );
223226
  }
224227
M src/main/java/com/keenwrite/Launcher.java
44
import java.io.IOException;
55
import java.io.InputStream;
6
import java.util.Calendar;
76
import java.util.Properties;
87
9
import static com.keenwrite.Bootstrap.APP_TITLE;
8
import static com.keenwrite.Bootstrap.*;
109
import static java.lang.String.format;
1110
...
3130
  @SuppressWarnings("RedundantStringFormatCall")
3231
  private static void showAppInfo() {
33
    out( format( "%s version %s", APP_TITLE, getVersion() ) );
34
    out( format( "Copyright 2016-%s White Magic Software, Ltd.", getYear() ) );
32
    out( format( "%s version %s", APP_TITLE, APP_VERSION ) );
33
    out( format( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ) );
3534
    out( format( "Portions copyright 2015-2020 Karl Tauber." ) );
3635
  }
...
5554
      throw new RuntimeException( ex );
5655
    }
57
  }
58
59
  private static String getYear() {
60
    return Integer.toString( Calendar.getInstance().get( Calendar.YEAR ) );
6156
  }
6257
M src/main/java/com/keenwrite/MainApp.java
33
44
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.service.Snitch;
65
import javafx.application.Application;
6
import javafx.event.Event;
7
import javafx.event.EventType;
8
import javafx.scene.input.KeyCode;
9
import javafx.scene.input.KeyEvent;
710
import javafx.stage.Stage;
811
912
import java.util.function.BooleanSupplier;
1013
import java.util.logging.LogManager;
1114
1215
import static com.keenwrite.Bootstrap.APP_TITLE;
1316
import static com.keenwrite.Constants.LOGOS;
14
import static com.keenwrite.preferences.Workspace.*;
17
import static com.keenwrite.preferences.WorkspaceKeys.*;
1518
import static com.keenwrite.util.FontLoader.initFonts;
19
import static javafx.scene.input.KeyCode.ALT;
1620
import static javafx.scene.input.KeyCode.F11;
1721
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
22
import static javafx.scene.input.KeyEvent.KEY_RELEASED;
1823
1924
/**
2025
 * Application entry point. The application allows users to edit plain text
2126
 * files in a markup notation and see a real-time preview of the formatted
2227
 * output.
2328
 */
24
@SuppressWarnings( {"FieldCanBeLocal", "unused", "RedundantSuppression"} )
2529
public final class MainApp extends Application {
26
27
  private final Snitch mSnitch = Services.load( Snitch.class );
2830
2931
  private Workspace mWorkspace;
...
6264
    initIcons( stage );
6365
    initScene( stage );
64
    initSnitch();
6566
6667
    stage.show();
...
9091
      if( F11.equals( event.getCode() ) ) {
9192
        stage.setFullScreen( !stage.isFullScreen() );
93
      }
94
    } );
95
96
    // After the app loses focus, when the user switches back using Alt+Tab,
97
    // 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.
100
    //
101
    // See: https://bugs.openjdk.java.net/browse/JDK-8090647
102
    stage.focusedProperty().addListener( ( c, lost, show ) -> {
103
      if( lost ) {
104
        for( final var mnemonics : stage.getScene().getMnemonics().values() ) {
105
          for( final var mnemonic : mnemonics ) {
106
            mnemonic.getNode().fireEvent( keyUp( ALT, false ) );
107
          }
108
        }
92109
      }
93110
    } );
...
100117
  private void initScene( final Stage stage ) {
101118
    stage.setScene( (new MainScene( mWorkspace )).getScene() );
102
  }
103
104
  /**
105
   * Watch for file system changes.
106
   */
107
  private void initSnitch() {
108
    getSnitch().start();
109119
  }
110120
...
123133
  }
124134
125
  private Snitch getSnitch() {
126
    return mSnitch;
135
  public static Event keyDown( final KeyCode code, final boolean shift ) {
136
    return keyEvent( KEY_PRESSED, code, shift );
137
  }
138
139
  public static Event keyUp( final KeyCode code, final boolean shift ) {
140
    return keyEvent( KEY_RELEASED, code, shift );
141
  }
142
143
  private static Event keyEvent(
144
    final EventType<KeyEvent> type, final KeyCode code, final boolean shift ) {
145
    return new KeyEvent(
146
      type, "", "", code, shift, false, false, false
147
    );
127148
  }
128149
}
M src/main/java/com/keenwrite/MainPane.java
5353
import static com.keenwrite.StatusNotifier.clue;
5454
import static com.keenwrite.io.MediaType.*;
55
import static com.keenwrite.preferences.Workspace.*;
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 org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
66
67
/**
68
 * Responsible for wiring together the main application components for a
69
 * particular workspace (project). These include the definition views,
70
 * text editors, and preview pane along with any corresponding controllers.
71
 */
72
public final class MainPane extends SplitPane {
73
  private static final Notifier sNotifier = Services.load( Notifier.class );
74
75
  /**
76
   * Used when opening files to determine how each file should be binned and
77
   * therefore what tab pane to be opened within.
78
   */
79
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
80
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED
81
  );
82
83
  /**
84
   * Prevents re-instantiation of processing classes.
85
   */
86
  private final Map<TextResource, Processor<String>> mProcessors =
87
    new HashMap<>();
88
89
  private final Workspace mWorkspace;
90
91
  /**
92
   * Groups similar file type tabs together.
93
   */
94
  private final Map<MediaType, DetachableTabPane> mTabPanes = new HashMap<>();
95
96
  /**
97
   * Stores definition names and values.
98
   */
99
  private final Map<String, String> mResolvedMap =
100
    new HashMap<>( MAP_SIZE_DEFAULT );
101
102
  /**
103
   * Renders the actively selected plain text editor tab.
104
   */
105
  private final HtmlPreview mHtmlPreview;
106
107
  /**
108
   * Changing the active editor fires the value changed event. This allows
109
   * refreshes to happen when external definitions are modified and need to
110
   * trigger the processing chain.
111
   */
112
  private final ObjectProperty<TextEditor> mActiveTextEditor =
113
    createActiveTextEditor();
114
115
  /**
116
   * Changing the active definition editor fires the value changed event. This
117
   * allows refreshes to happen when external definitions are modified and need
118
   * to trigger the processing chain.
119
   */
120
  private final ObjectProperty<TextDefinition> mActiveDefinitionEditor =
121
    createActiveDefinitionEditor( mActiveTextEditor );
122
123
  /**
124
   * Responsible for creating a new scene when a tab is detached into
125
   * its own window frame.
126
   */
127
  private final DefinitionTabSceneFactory mDefinitionTabSceneFactory =
128
    createDefinitionTabSceneFactory( mActiveDefinitionEditor );
129
130
  /**
131
   * Tracks the number of detached tab panels opened into their own windows,
132
   * which allows unique identification of subordinate windows by their title.
133
   * It is doubtful more than 128 windows, much less 256, will be created.
134
   */
135
  private byte mWindowCount;
136
137
  /**
138
   * Called when the definition data is changed.
139
   */
140
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
141
    event -> {
142
      final var editor = mActiveDefinitionEditor.get();
143
144
      resolve( editor );
145
      process( getActiveTextEditor() );
146
      save( editor );
147
    };
148
149
  /**
150
   * Adds all content panels to the main user interface. This will load the
151
   * configuration settings from the workspace to reproduce the settings from
152
   * a previous session.
153
   */
154
  public MainPane( final Workspace workspace ) {
155
    mWorkspace = workspace;
156
    mHtmlPreview = new HtmlPreview( workspace );
157
158
    open( bin( getRecentFiles() ) );
159
    viewPreview();
160
    setDividerPositions( calculateDividerPositions() );
161
162
    // Once the main scene's window regains focus, update the active definition
163
    // editor to the currently selected tab.
164
    runLater(
165
      () -> {
166
        getWindow().focusedProperty().addListener( ( c, o, n ) -> {
167
          if( n != null && n ) {
168
            final var pane = mTabPanes.get( TEXT_YAML );
169
            final var model = pane.getSelectionModel();
170
            final var tab = model.getSelectedItem();
171
172
            if( tab != null ) {
173
              final var resource = tab.getContent();
174
175
              if( resource instanceof TextDefinition ) {
176
                mActiveDefinitionEditor.set( (TextDefinition) tab.getContent() );
177
              }
178
            }
179
          }
180
        } );
181
182
        getWindow().setOnCloseRequest( ( event ) -> {
183
          // Order matters here. We want to close all the tabs to ensure each
184
          // is saved, but after they are closed, the workspace should still
185
          // retain the list of files that were open. If this line came after
186
          // closing, then restarting the application would list no files.
187
          mWorkspace.save();
188
189
          if( closeAll() ) {
190
            Platform.exit();
191
            System.exit( 0 );
192
          }
193
          else {
194
            event.consume();
195
          }
196
        } );
197
      }
198
    );
199
  }
200
201
  /**
202
   * TODO: Load divider positions from exported settings, see bin() comment.
203
   */
204
  private double[] calculateDividerPositions() {
205
    final var ratio = 100f / getItems().size() / 100;
206
    final var positions = getDividerPositions();
207
208
    for( int i = 0; i < positions.length; i++ ) {
209
      positions[ i ] = ratio * i;
210
    }
211
212
    return positions;
213
  }
214
215
  /**
216
   * Opens all the files into the application, provided the paths are unique.
217
   * This may only be called for any type of files that a user can edit
218
   * (i.e., update and persist), such as definitions and text files.
219
   *
220
   * @param files The list of files to open.
221
   */
222
  public void open( final List<File> files ) {
223
    files.forEach( this::open );
224
  }
225
226
  /**
227
   * This opens the given file. Since the preview pane is not a file that
228
   * can be opened, it is safe to add a listener to the detachable pane.
229
   *
230
   * @param file The file to open.
231
   */
232
  private void open( final File file ) {
233
    final var tab = createTab( file );
234
    final var node = tab.getContent();
235
    final var mediaType = MediaType.valueFrom( file );
236
    final var tabPane = obtainDetachableTabPane( mediaType );
237
    final var newTabPane = !getItems().contains( tabPane );
238
239
    tab.setTooltip( createTooltip( file ) );
240
    tabPane.setFocusTraversable( false );
241
    tabPane.setTabClosingPolicy( ALL_TABS );
242
    tabPane.getTabs().add( tab );
243
244
    if( newTabPane ) {
245
      var index = getItems().size();
246
247
      if( node instanceof TextDefinition ) {
248
        tabPane.setSceneFactory( mDefinitionTabSceneFactory::create );
249
        index = 0;
250
      }
251
252
      addTabPane( index, tabPane );
253
    }
254
255
    getRecentFiles().add( file.getAbsolutePath() );
256
  }
257
258
  /**
259
   * Opens a new text editor document using the default document file name.
260
   */
261
  public void newTextEditor() {
262
    open( DOCUMENT_DEFAULT );
263
  }
264
265
  /**
266
   * Opens a new definition editor document using the default definition
267
   * file name.
268
   */
269
  public void newDefinitionEditor() {
270
    open( DEFINITION_DEFAULT );
271
  }
272
273
  /**
274
   * Iterates over all tab panes to find all {@link TextEditor}s and request
275
   * that they save themselves.
276
   */
277
  public void saveAll() {
278
    mTabPanes.forEach(
279
      ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> {
280
        final var node = tab.getContent();
281
        if( node instanceof TextEditor ) {
282
          save( ((TextEditor) node) );
283
        }
284
      } )
285
    );
286
  }
287
288
  /**
289
   * Requests that the active {@link TextEditor} saves itself. Don't bother
290
   * checking if modified first because if the user swaps external media from
291
   * an external source (e.g., USB thumb drive), save should not second-guess
292
   * the user: save always re-saves. Also, it's less code.
293
   */
294
  public void save() {
295
    save( getActiveTextEditor() );
296
  }
297
298
  /**
299
   * Saves the active {@link TextEditor} under a new name.
300
   *
301
   * @param file The new active editor {@link File} reference.
302
   */
303
  public void saveAs( final File file ) {
304
    assert file != null;
305
    final var editor = getActiveTextEditor();
306
    final var tab = getTab( editor );
307
308
    editor.rename( file );
309
    tab.ifPresent( t -> {
310
      t.setText( editor.getFilename() );
311
      t.setTooltip( createTooltip( file ) );
312
    } );
313
314
    save();
315
  }
316
317
  /**
318
   * Saves the given {@link TextResource} to a file. This is typically used
319
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
320
   *
321
   * @param resource The resource to export.
322
   */
323
  private void save( final TextResource resource ) {
324
    try {
325
      resource.save();
326
    } catch( final Exception ex ) {
327
      clue( ex );
328
      sNotifier.alert(
329
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
330
      );
331
    }
332
  }
333
334
  /**
335
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
336
   *
337
   * @return {@code true} when all editors, modified or otherwise, were
338
   * permitted to close; {@code false} when one or more editors were modified
339
   * and the user requested no closing.
340
   */
341
  public boolean closeAll() {
342
    var closable = true;
343
344
    for( final var entry : mTabPanes.entrySet() ) {
345
      final var tabPane = entry.getValue();
346
      final var tabIterator = tabPane.getTabs().iterator();
347
348
      while( tabIterator.hasNext() ) {
349
        final var tab = tabIterator.next();
350
        final var resource = tab.getContent();
351
352
        if( !(resource instanceof TextResource) ) {
353
          continue;
354
        }
355
356
        if( canClose( (TextResource) resource ) ) {
357
          tabIterator.remove();
358
          close( tab );
359
        }
360
        else {
361
          closable = false;
362
        }
363
      }
364
    }
365
366
    return closable;
367
  }
368
369
  /**
370
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
371
   * event.
372
   *
373
   * @param tab The {@link Tab} that was closed.
374
   */
375
  private void close( final Tab tab ) {
376
    final var handler = tab.getOnClosed();
377
378
    if( handler != null ) {
379
      handler.handle( new ActionEvent() );
380
    }
381
  }
382
383
  /**
384
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
385
   */
386
  public void close() {
387
    final var editor = getActiveTextEditor();
388
    if( canClose( editor ) ) {
389
      close( editor );
390
    }
391
  }
392
393
  /**
394
   * Closes the given {@link TextResource}. This must not be called from within
395
   * a loop that iterates over the tab panes using {@code forEach}, lest a
396
   * concurrent modification exception be thrown.
397
   *
398
   * @param resource The {@link TextResource} to close, without confirming with
399
   *                 the user.
400
   */
401
  private void close( final TextResource resource ) {
402
    getTab( resource ).ifPresent(
403
      ( tab ) -> {
404
        tab.getTabPane().getTabs().remove( tab );
405
        close( tab );
406
      }
407
    );
408
  }
409
410
  /**
411
   * Answers whether the given {@link TextResource} may be closed.
412
   *
413
   * @param editor The {@link TextResource} to try closing.
414
   * @return {@code true} when the editor may be closed; {@code false} when
415
   * the user has requested to keep the editor open.
416
   */
417
  private boolean canClose( final TextResource editor ) {
418
    final var editorTab = getTab( editor );
419
    final var canClose = new AtomicBoolean( true );
420
421
    if( editor.isModified() ) {
422
      final var filename = new StringBuilder();
423
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
424
425
      final var message = sNotifier.createNotification(
426
        Messages.get( "Alert.file.close.title" ),
427
        Messages.get( "Alert.file.close.text" ),
428
        filename.toString()
429
      );
430
431
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
432
433
      dialog.showAndWait().ifPresent(
434
        save -> canClose.set( save == YES ? editor.save() : save == NO )
435
      );
436
    }
437
438
    return canClose.get();
439
  }
440
441
  private ObjectProperty<TextEditor> createActiveTextEditor() {
442
    final var editor = new SimpleObjectProperty<TextEditor>();
443
444
    editor.addListener( ( c, o, n ) -> {
445
      if( n != null ) {
446
        mHtmlPreview.setBaseUri( n.getPath() );
447
        process( n );
448
      }
449
    } );
450
451
    return editor;
452
  }
453
454
  /**
455
   * Adds the HTML preview tab to its own tab pane. This will only add the
456
   * preview once.
457
   */
458
  public void viewPreview() {
459
    final var tabPane = obtainDetachableTabPane( TEXT_HTML );
460
461
    // Prevent multiple HTML previews because in the end, there can be only one.
462
    for( final var tab : tabPane.getTabs() ) {
463
      if( tab.getContent() == mHtmlPreview ) {
464
        return;
465
      }
466
    }
467
468
    tabPane.addTab( "HTML", mHtmlPreview );
469
    addTabPane( tabPane );
470
  }
471
472
  public void viewRefresh() {
473
    mHtmlPreview.refresh();
474
  }
475
476
  /**
477
   * Returns the tab that contains the given {@link TextEditor}.
478
   *
479
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
480
   * @return The first tab having content that matches the given tab.
481
   */
482
  private Optional<Tab> getTab( final TextResource editor ) {
483
    return mTabPanes.values()
484
                    .stream()
485
                    .flatMap( pane -> pane.getTabs().stream() )
486
                    .filter( tab -> editor.equals( tab.getContent() ) )
487
                    .findFirst();
488
  }
489
490
  /**
491
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
492
   * is used to detect when the active {@link DefinitionEditor} has changed.
493
   * Upon changing, the {@link #mResolvedMap} is updated and the active
494
   * text editor is refreshed.
495
   *
496
   * @param editor Text editor to update with the revised resolved map.
497
   * @return A newly configured property that represents the active
498
   * {@link DefinitionEditor}, never null.
499
   */
500
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
501
    final ObjectProperty<TextEditor> editor ) {
502
    final var definitions = new SimpleObjectProperty<TextDefinition>();
503
    definitions.addListener( ( c, o, n ) -> {
504
      resolve( n == null ? createDefinitionEditor() : n );
505
      process( editor.get() );
506
    } );
507
508
    return definitions;
509
  }
510
511
  /**
512
   * Instantiates a factory that's responsible for creating new scenes when
513
   * a tab is dropped outside of any application window. The definition tabs
514
   * are fairly complex in that only one may be active at any time. When
515
   * activated, the {@link #mResolvedMap} must be updated to reflect the
516
   * hierarchy displayed in the {@link DefinitionEditor}.
517
   *
518
   * @param activeDefinitionEditor The current {@link DefinitionEditor}.
519
   * @return An object that listens to {@link DefinitionEditor} tab focus
520
   * changes.
521
   */
522
  private DefinitionTabSceneFactory createDefinitionTabSceneFactory(
523
    final ObjectProperty<TextDefinition> activeDefinitionEditor ) {
524
    return new DefinitionTabSceneFactory( ( tab ) -> {
525
      assert tab != null;
526
527
      var node = tab.getContent();
528
      if( node instanceof TextDefinition ) {
529
        activeDefinitionEditor.set( (DefinitionEditor) node );
530
      }
531
    } );
532
  }
533
534
  private DetachableTab createTab( final File file ) {
535
    final var r = createTextResource( file );
536
    final var tab = new DetachableTab( r.getFilename(), r.getNode() );
537
538
    r.modifiedProperty().addListener(
539
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
540
    );
541
542
    // This is called when either the tab is closed by the user clicking on
543
    // the tab's close icon or when closing (all) from the file menu.
544
    tab.setOnClosed(
545
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
546
    );
547
548
    return tab;
549
  }
550
551
  /**
552
   * Creates bins for the different {@link MediaType}s, which eventually are
553
   * added to the UI as separate tab panes. If ever a general-purpose scene
554
   * exporter is developed to serialize a scene to an FXML file, this could
555
   * be replaced by such a class.
556
   * <p>
557
   * When binning the files, this makes sure that at least one file exists
558
   * for every type. If the user has opted to close a particular type (such
559
   * as the definition pane), the view will suppressed elsewhere.
560
   * </p>
561
   * <p>
562
   * The order that the binned files are returned will be reflected in the
563
   * order that the corresponding panes are rendered in the UI.
564
   * </p>
565
   *
566
   * @param paths The file paths to bin according to their type.
567
   * @return An in-order list of files, first by structured definition files,
568
   * then by plain text documents.
569
   */
570
  private List<File> bin( final SetProperty<String> paths ) {
571
    // Treat all files destined for the text editor as plain text documents
572
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
573
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
574
    final Function<MediaType, MediaType> bin =
575
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
576
577
    // Create two groups: YAML files and plain text files.
578
    final var bins = paths
579
      .stream()
580
      .collect(
581
        groupingBy( path -> bin.apply( MediaType.valueFrom( path ) ) )
582
      );
583
584
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
585
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
586
587
    final var result = new ArrayList<File>( paths.size() );
588
589
    // Ensure that the same types are listed together (keep insertion order).
590
    bins.forEach( ( mediaType, files ) -> result.addAll(
591
      files.stream().map( File::new ).collect( Collectors.toList() ) )
592
    );
593
594
    return result;
595
  }
596
597
  /**
598
   * Uses the given {@link TextDefinition} instance to update the
599
   * {@link #mResolvedMap}.
600
   *
601
   * @param editor A non-null, possibly empty definition editor.
602
   */
603
  private void resolve( final TextDefinition editor ) {
604
    assert editor != null;
605
606
    final var tokens = createDefinitionTokens();
607
    final var operator = new YamlSigilOperator( tokens );
608
    final var map = new HashMap<String, String>();
609
610
    editor.toMap().forEach( ( k, v ) -> map.put( operator.entoken( k ), v ) );
611
612
    mResolvedMap.clear();
613
    mResolvedMap.putAll( editor.interpolate( map, tokens ) );
614
  }
615
616
  /**
617
   * Force the active editor to update, which will cause the processor
618
   * to re-evaluate the interpolated definition map thereby updating the
619
   * preview pane.
620
   *
621
   * @param editor Contains the source document to update in the preview pane.
622
   */
623
  private void process( final TextEditor editor ) {
624
    mProcessors.getOrDefault( editor, IdentityProcessor.IDENTITY )
625
               .apply( editor == null ? "" : editor.getText() );
626
    mHtmlPreview.scrollTo( CARET_ID );
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
    } );
627634
  }
628635
M src/main/java/com/keenwrite/MainScene.java
22
package com.keenwrite;
33
4
import com.keenwrite.io.FileModifiedListener;
5
import com.keenwrite.io.FileWatchService;
46
import com.keenwrite.preferences.Workspace;
57
import com.keenwrite.ui.actions.ApplicationActions;
68
import com.keenwrite.ui.listeners.CaretListener;
9
import javafx.scene.AccessibleRole;
710
import javafx.scene.Node;
811
import javafx.scene.Parent;
912
import javafx.scene.Scene;
1013
import javafx.scene.layout.BorderPane;
1114
import javafx.scene.layout.VBox;
1215
import org.controlsfx.control.StatusBar;
1316
14
import static com.keenwrite.Constants.STYLESHEET_SCENE;
17
import java.io.File;
18
19
import static com.keenwrite.Constants.*;
20
import static com.keenwrite.Messages.get;
21
import static com.keenwrite.StatusNotifier.clue;
1522
import static com.keenwrite.StatusNotifier.getStatusBar;
23
import static com.keenwrite.preferences.ThemeProperty.toFilename;
24
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_CUSTOM;
25
import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_SELECTION;
1626
import static com.keenwrite.ui.actions.ApplicationBars.createMenuBar;
1727
import static com.keenwrite.ui.actions.ApplicationBars.createToolBar;
28
import static javafx.application.Platform.runLater;
29
import static javafx.scene.input.KeyCode.ALT;
30
import static javafx.scene.input.KeyCode.ALT_GRAPH;
31
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
1832
1933
/**
2034
 * Responsible for creating the bar scene: menu bar, tool bar, and status bar.
2135
 */
2236
public final class MainScene {
2337
  private final Scene mScene;
2438
  private final Node mMenuBar;
2539
  private final Node mToolBar;
2640
  private final StatusBar mStatusBar;
41
  private final FileWatchService mFileWatchService = new FileWatchService();
42
  private FileModifiedListener mStylesheetFileListener = event -> {};
2743
2844
  public MainScene( final Workspace workspace ) {
...
4056
    appPane.setCenter( mainPane );
4157
    appPane.setBottom( mStatusBar );
58
59
    final var watchThread = new Thread( mFileWatchService );
60
    watchThread.setDaemon( true );
61
    watchThread.start();
4262
4363
    mScene = createScene( appPane );
64
    initStylesheets( mScene, workspace );
4465
  }
4566
...
6788
    final var node = mStatusBar;
6889
    node.setVisible( !node.isVisible() );
90
  }
91
92
  private void initStylesheets( final Scene scene, final Workspace workspace ) {
93
    final var internal = workspace.themeProperty( KEY_UI_THEME_SELECTION );
94
    final var external = workspace.fileProperty( KEY_UI_THEME_CUSTOM );
95
    final var inTheme = internal.get();
96
    final var exTheme = external.get();
97
    applyStylesheets( scene, inTheme, exTheme );
98
99
    internal.addListener(
100
      ( c, o, n ) -> applyStylesheets( scene, inTheme, exTheme )
101
    );
102
103
    external.addListener(
104
      ( c, o, n ) -> {
105
        if( o != null ) {
106
          mFileWatchService.unregister( o );
107
        }
108
109
        if( n != null ) {
110
          try {
111
            applyStylesheets( scene, inTheme, n );
112
          } catch( final Exception ex ) {
113
            // Changes to the CSS file won't autoload, which is okay.
114
            clue( ex );
115
          }
116
        }
117
      }
118
    );
119
120
    mFileWatchService.removeListener( mStylesheetFileListener );
121
    mStylesheetFileListener = event ->
122
      runLater( () -> applyStylesheets( scene, inTheme, event.getFile() ) );
123
    mFileWatchService.addListener( mStylesheetFileListener );
124
  }
125
126
  private String getStylesheet( final String filename ) {
127
    return get( STYLESHEET_APPLICATION_THEME, filename );
128
  }
129
130
  /**
131
   * Clears then re-applies all the internal stylesheets.
132
   *
133
   * @param scene    The scene to stylize.
134
   * @param internal The CSS file name bundled with the application.
135
   */
136
  private void applyStylesheets(
137
    final Scene scene, final String internal, final File external ) {
138
    final var stylesheets = scene.getStylesheets();
139
    stylesheets.clear();
140
    stylesheets.add( STYLESHEET_APPLICATION_BASE );
141
    stylesheets.add( STYLESHEET_MARKDOWN );
142
    stylesheets.add( getStylesheet( toFilename( internal ) ) );
143
144
    try {
145
      if( external.canRead() && !external.isDirectory() ) {
146
        stylesheets.add( external.toURI().toURL().toString() );
147
148
        mFileWatchService.register( external );
149
      }
150
    } catch( final Exception ex ) {
151
      clue( ex );
152
    }
69153
  }
70154
71155
  private MainPane createMainPane( final Workspace workspace ) {
72156
    return new MainPane( workspace );
73157
  }
74158
75159
  private ApplicationActions createApplicationActions(
76160
    final MainPane mainPane ) {
77161
    return new ApplicationActions( this, mainPane );
78
  }
79
80
  private Scene createScene( final Parent parent ) {
81
    final var scene = new Scene( parent );
82
    final var stylesheets = scene.getStylesheets();
83
    stylesheets.add( STYLESHEET_SCENE );
84
85
    return scene;
86162
  }
87163
...
95171
  private CaretListener createCaretListener( final MainPane mainPane ) {
96172
    return new CaretListener( mainPane.activeTextEditorProperty() );
173
  }
174
175
  /**
176
   * Creates a new scene that is attached to the given {@link Parent}.
177
   *
178
   * @param parent The container for the scene.
179
   * @return A scene to capture user interactions, UI styles, etc.
180
   */
181
  private Scene createScene( final Parent parent ) {
182
    return new Scene( parent );
97183
  }
98184
99185
  /**
100186
   * Binds the visible property of the node to the managed property so that
101187
   * hiding the node also removes the screen real estate that it occupies.
188
   * This allows the user to hide the menu bar, tool bar, etc.
102189
   *
103190
   * @param node The node to have its real estate bound to visibility.
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
44
import com.keenwrite.Caret;
55
import com.keenwrite.Constants;
6
import com.keenwrite.editors.TextEditor;
7
import com.keenwrite.preferences.LocaleProperty;
8
import com.keenwrite.preferences.Workspace;
9
import com.keenwrite.spelling.impl.TextEditorSpeller;
10
import javafx.beans.binding.Bindings;
11
import javafx.beans.property.*;
12
import javafx.beans.value.ChangeListener;
13
import javafx.event.Event;
14
import javafx.scene.Node;
15
import javafx.scene.control.IndexRange;
16
import javafx.scene.input.KeyCode;
17
import javafx.scene.input.KeyEvent;
18
import javafx.scene.layout.BorderPane;
19
import org.fxmisc.flowless.VirtualizedScrollPane;
20
import org.fxmisc.richtext.StyleClassedTextArea;
21
import org.fxmisc.richtext.model.StyleSpans;
22
import org.fxmisc.undo.UndoManager;
23
import org.fxmisc.wellbehaved.event.EventPattern;
24
import org.fxmisc.wellbehaved.event.Nodes;
25
26
import java.io.File;
27
import java.nio.charset.Charset;
28
import java.text.BreakIterator;
29
import java.util.*;
30
import java.util.function.Consumer;
31
import java.util.function.Supplier;
32
import java.util.regex.Pattern;
33
34
import static com.keenwrite.Constants.*;
35
import static com.keenwrite.Messages.get;
36
import static com.keenwrite.StatusNotifier.clue;
37
import static com.keenwrite.preferences.Workspace.*;
38
import static java.lang.Character.isWhitespace;
39
import static java.lang.Math.max;
40
import static java.lang.String.format;
41
import static java.util.Collections.singletonList;
42
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
43
import static javafx.scene.input.KeyCode.*;
44
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
45
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
46
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
47
import static org.apache.commons.lang3.StringUtils.stripEnd;
48
import static org.apache.commons.lang3.StringUtils.stripStart;
49
import static org.fxmisc.richtext.model.StyleSpans.singleton;
50
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
51
import static org.fxmisc.wellbehaved.event.InputMap.consume;
52
53
/**
54
 * Responsible for editing Markdown documents.
55
 */
56
public final class MarkdownEditor extends BorderPane implements TextEditor {
57
  /**
58
   * Regular expression that matches the type of markup block. This is used
59
   * when Enter is pressed to continue the block environment.
60
   */
61
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
62
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
63
64
  /**
65
   * The text editor.
66
   */
67
  private final StyleClassedTextArea mTextArea =
68
    new StyleClassedTextArea( false );
69
70
  /**
71
   * Wraps the text editor in scrollbars.
72
   */
73
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
74
    new VirtualizedScrollPane<>( mTextArea );
75
76
  private final Workspace mWorkspace;
77
78
  /**
79
   * Tracks where the caret is located in this document. This offers observable
80
   * properties for caret position changes.
81
   */
82
  private final Caret mCaret = createCaret( mTextArea );
83
84
  /**
85
   * File being edited by this editor instance.
86
   */
87
  private File mFile;
88
89
  /**
90
   * Set to {@code true} upon text or caret position changes. Value is {@code
91
   * false} by default.
92
   */
93
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
94
95
  /**
96
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
97
   * either no encoding could be determined or this is a new (empty) file.
98
   */
99
  private final Charset mEncoding;
100
101
  /**
102
   * Tracks whether the in-memory definitions have changed with respect to the
103
   * persisted definitions.
104
   */
105
  private final BooleanProperty mModified = new SimpleBooleanProperty();
106
107
  public MarkdownEditor( final Workspace workspace ) {
108
    this( DOCUMENT_DEFAULT, workspace );
109
  }
110
111
  public MarkdownEditor( final File file, final Workspace workspace ) {
112
    mEncoding = open( mFile = file );
113
    mWorkspace = workspace;
114
115
    initTextArea( mTextArea );
116
    initStyle( mTextArea );
117
    initScrollPane( mScrollPane );
118
    initSpellchecker( mTextArea );
119
    initHotKeys();
120
    initUndoManager();
121
  }
122
123
  private void initTextArea( final StyleClassedTextArea textArea ) {
124
    textArea.setWrapText( true );
125
    textArea.requestFollowCaret();
126
    textArea.moveTo( 0 );
127
128
    textArea.textProperty().addListener( ( c, o, n ) -> {
129
      // Fire, regardless of whether the caret position has changed.
130
      mDirty.set( false );
131
132
      // Prevent a caret position change from raising the dirty bits.
133
      mDirty.set( true );
134
    } );
135
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
136
      // Fire when the caret position has changed and the text has not.
137
      mDirty.set( true );
138
      mDirty.set( false );
139
    } );
140
  }
141
142
  private void initStyle( final StyleClassedTextArea textArea ) {
143
    textArea.getStyleClass().add( "markdown" );
144
145
    final var stylesheets = textArea.getStylesheets();
146
    stylesheets.add( STYLESHEET_MARKDOWN );
147
    stylesheets.add( getStylesheetPath( getLocale() ) );
148
149
    localeProperty().addListener( ( c, o, n ) -> {
150
      if( n != null ) {
151
        stylesheets.remove( max( 0, stylesheets.size() - 1 ) );
152
        stylesheets.add( getStylesheetPath( getLocale() ) );
153
      }
154
    } );
155
156
    fontNameProperty().addListener(
157
      ( c, o, n ) -> {
158
        mTextArea.setStyle( format( "-fx-font-family: '%s';", getFontName() ) );
159
      }
160
    );
161
162
    fontSizeProperty().addListener(
163
      ( c, o, n ) ->
164
        mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) )
165
    );
166
  }
167
168
  private void initScrollPane(
169
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
170
    scrollpane.setVbarPolicy( ALWAYS );
171
    setCenter( scrollpane );
172
  }
173
174
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
175
    final var speller = new TextEditorSpeller();
176
    speller.checkDocument( textarea );
177
    speller.checkParagraphs( textarea );
178
  }
179
180
  private void initHotKeys() {
181
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
182
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
183
    addEventListener( keyPressed( TAB ), this::tab );
184
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
185
  }
186
187
  private void initUndoManager() {
188
    final var undoManager = getUndoManager();
189
    final var markedPosition = undoManager.atMarkedPositionProperty();
190
191
    undoManager.forgetHistory();
192
    undoManager.mark();
193
    mModified.bind( Bindings.not( markedPosition ) );
194
  }
195
196
  @Override
197
  public void moveTo( final int offset ) {
198
    assert 0 <= offset && offset <= mTextArea.getLength();
199
    mTextArea.moveTo( offset );
200
    mTextArea.requestFollowCaret();
201
  }
202
203
  /**
204
   * Delegate the focus request to the text area itself.
205
   */
206
  @Override
207
  public void requestFocus() {
208
    mTextArea.requestFocus();
209
  }
210
211
  @Override
212
  public void setText( final String text ) {
213
    mTextArea.clear();
214
    mTextArea.appendText( text );
215
    mTextArea.getUndoManager().mark();
216
  }
217
218
  @Override
219
  public String getText() {
220
    return mTextArea.getText();
221
  }
222
223
  @Override
224
  public Charset getEncoding() {
225
    return mEncoding;
226
  }
227
228
  @Override
229
  public File getFile() {
230
    return mFile;
231
  }
232
233
  @Override
234
  public void rename( final File file ) {
235
    mFile = file;
236
  }
237
238
  @Override
239
  public void undo() {
240
    final var manager = getUndoManager();
241
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
242
  }
243
244
  @Override
245
  public void redo() {
246
    final var manager = getUndoManager();
247
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
248
  }
249
250
  /**
251
   * Performs an undo or redo action, if possible, otherwise displays an error
252
   * message to the user.
253
   *
254
   * @param ready  Answers whether the action can be executed.
255
   * @param action The action to execute.
256
   * @param key    The informational message key having a value to display if
257
   *               the {@link Supplier} is not ready.
258
   */
259
  private void xxdo(
260
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
261
    if( ready.get() ) {
262
      action.run();
263
    }
264
    else {
265
      clue( key );
266
    }
267
  }
268
269
  @Override
270
  public void cut() {
271
    final var selected = mTextArea.getSelectedText();
272
273
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
274
    if( selected == null || selected.isEmpty() ) {
275
      // Note: mTextArea.selectLine() does not select empty lines.
276
      mTextArea.fireEvent( keyEvent( HOME, false ) );
277
      mTextArea.fireEvent( keyEvent( DOWN, true ) );
278
    }
279
280
    mTextArea.cut();
281
  }
282
283
  private Event keyEvent( final KeyCode code, final boolean shift ) {
284
    return new KeyEvent(
285
      KEY_PRESSED, "", "", code, shift, false, false, false
286
    );
287
  }
288
289
  @Override
290
  public void copy() {
291
    mTextArea.copy();
292
  }
293
294
  @Override
295
  public void paste() {
296
    mTextArea.paste();
297
  }
298
299
  @Override
300
  public void selectAll() {
301
    mTextArea.selectAll();
302
  }
303
304
  @Override
305
  public void bold() {
306
    enwrap( "**" );
307
  }
308
309
  @Override
310
  public void italic() {
311
    enwrap( "*" );
312
  }
313
314
  @Override
315
  public void superscript() {
316
    enwrap( "^" );
317
  }
318
319
  @Override
320
  public void subscript() {
321
    enwrap( "~" );
322
  }
323
324
  @Override
325
  public void strikethrough() {
326
    enwrap( "~~" );
327
  }
328
329
  @Override
330
  public void blockquote() {
331
    block( "> " );
332
  }
333
334
  @Override
335
  public void code() {
336
    enwrap( "`" );
337
  }
338
339
  @Override
340
  public void fencedCodeBlock() {
341
    enwrap( "\n\n```\n", "\n```\n\n" );
342
  }
343
344
  @Override
345
  public void heading( final int level ) {
346
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
347
    block( format( "%s ", hashes ) );
348
  }
349
350
  @Override
351
  public void unorderedList() {
352
    block( "* " );
353
  }
354
355
  @Override
356
  public void orderedList() {
357
    block( "1. " );
358
  }
359
360
  @Override
361
  public void horizontalRule() {
362
    block( format( "---%n%n" ) );
363
  }
364
365
  @Override
366
  public Node getNode() {
367
    return this;
368
  }
369
370
  @Override
371
  public ReadOnlyBooleanProperty modifiedProperty() {
372
    return mModified;
373
  }
374
375
  @Override
376
  public void clearModifiedProperty() {
377
    getUndoManager().mark();
378
  }
379
380
  @Override
381
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
382
    return mScrollPane;
383
  }
384
385
  @Override
386
  public StyleClassedTextArea getTextArea() {
387
    return mTextArea;
388
  }
389
390
  private final Map<String, IndexRange> mStyles = new HashMap<>();
391
392
  @Override
393
  public void stylize( final IndexRange range, final String style ) {
394
    final var began = range.getStart();
395
    final var ended = range.getEnd() + 1;
396
397
    assert 0 <= began && began <= ended;
398
    assert style != null;
399
400
    // TODO: Ensure spell check and find highlights can coexist.
401
//    final var spans = mTextArea.getStyleSpans( range );
402
//    System.out.println( "SPANS: " + spans );
403
404
//    final var spans = mTextArea.getStyleSpans( range );
405
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
406
//    ) );
407
408
//    final var builder = new StyleSpansBuilder<Collection<String>>();
409
//    builder.add( singleton( style ), range.getLength() + 1 );
410
//    mTextArea.setStyleSpans( began, builder.create() );
411
412
//    final var s = mTextArea.getStyleSpans( began, ended );
413
//    System.out.println( "STYLES: " +s );
414
415
    mStyles.put( style, range );
416
    mTextArea.setStyleClass( began, ended, style );
417
418
    // Ensure that whenever the user interacts with the text that the found
419
    // word will have its highlighting removed. The handler removes itself.
420
    // This won't remove the highlighting if the caret position moves by mouse.
421
    final var handler = mTextArea.getOnKeyPressed();
422
    mTextArea.setOnKeyPressed( ( event ) -> {
423
      mTextArea.setOnKeyPressed( handler );
424
      unstylize( style );
425
    } );
426
427
    //mTextArea.setStyleSpans(began, ended, s);
428
  }
429
430
  private static StyleSpans<Collection<String>> merge(
431
    StyleSpans<Collection<String>> spans, int len, String style ) {
432
    spans = spans.overlay(
433
      singleton( singletonList( style ), len ),
434
      ( bottomSpan, list ) -> {
435
        final List<String> l =
436
          new ArrayList<>( bottomSpan.size() + list.size() );
437
        l.addAll( bottomSpan );
438
        l.addAll( list );
439
        return l;
440
      } );
441
442
    return spans;
443
  }
444
445
  @Override
446
  public void unstylize( final String style ) {
447
    final var indexes = mStyles.remove( style );
448
    if( indexes != null ) {
449
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
450
    }
451
  }
452
453
  @Override
454
  public Caret getCaret() {
455
    return mCaret;
456
  }
457
458
  private Caret createCaret( final StyleClassedTextArea editor ) {
459
    return Caret
460
      .builder()
461
      .with( Caret.Mutator::setEditor, editor )
462
      .build();
463
  }
464
465
  /**
466
   * This method adds listeners to editor events.
467
   *
468
   * @param <T>      The event type.
469
   * @param <U>      The consumer type for the given event type.
470
   * @param event    The event of interest.
471
   * @param consumer The method to call when the event happens.
472
   */
473
  public <T extends Event, U extends T> void addEventListener(
474
    final EventPattern<? super T, ? extends U> event,
475
    final Consumer<? super U> consumer ) {
476
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
477
  }
478
479
  @SuppressWarnings( "unused" )
480
  private void onEnterPressed( final KeyEvent event ) {
481
    final var currentLine = getCaretParagraph();
482
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
483
484
    // By default, insert a new line by itself.
485
    String newText = NEWLINE;
486
487
    // If the pattern was matched then determine what block type to continue.
488
    if( matcher.matches() ) {
489
      if( matcher.group( 2 ).isEmpty() ) {
490
        final var pos = mTextArea.getCaretPosition();
491
        mTextArea.selectRange( pos - currentLine.length(), pos );
492
      }
493
      else {
494
        // Indent the new line with the same whitespace characters and
495
        // list markers as current line. This ensures that the indentation
496
        // is propagated.
497
        newText = newText.concat( matcher.group( 1 ) );
498
      }
499
    }
500
501
    mTextArea.replaceSelection( newText );
502
  }
503
504
  private void cut( final KeyEvent event ) {
505
    cut();
506
  }
507
508
  private void tab( final KeyEvent event ) {
509
    final var range = mTextArea.selectionProperty().getValue();
510
    final var sb = new StringBuilder( 1024 );
511
512
    if( range.getLength() > 0 ) {
513
      final var selection = mTextArea.getSelectedText();
514
515
      selection.lines().forEach(
516
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
517
      );
518
    }
519
    else {
520
      sb.append( "\t" );
521
    }
522
523
    mTextArea.replaceSelection( sb.toString() );
524
  }
525
526
  private void untab( final KeyEvent event ) {
527
    final var range = mTextArea.selectionProperty().getValue();
528
529
    if( range.getLength() > 0 ) {
530
      final var selection = mTextArea.getSelectedText();
531
      final var sb = new StringBuilder( selection.length() );
532
533
      selection.lines().forEach(
534
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
535
                   .append( NEWLINE )
536
      );
537
538
      mTextArea.replaceSelection( sb.toString() );
539
    }
540
    else {
541
      final var p = getCaretParagraph();
542
543
      if( p.startsWith( "\t" ) ) {
544
        mTextArea.selectParagraph();
545
        mTextArea.replaceSelection( p.substring( 1 ) );
546
      }
547
    }
548
  }
549
550
  /**
551
   * Observers may listen for changes to the property returned from this method
552
   * to receive notifications when either the text or caret have changed. This
553
   * should not be used to track whether the text has been modified.
554
   */
555
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
556
    mDirty.addListener( listener );
557
  }
558
559
  /**
560
   * Surrounds the selected text or word under the caret in Markdown markup.
561
   *
562
   * @param token The beginning and ending token for enclosing the text.
563
   */
564
  private void enwrap( final String token ) {
565
    enwrap( token, token );
566
  }
567
568
  /**
569
   * Surrounds the selected text or word under the caret in Markdown markup.
570
   *
571
   * @param began The beginning token for enclosing the text.
572
   * @param ended The ending token for enclosing the text.
573
   */
574
  private void enwrap( final String began, String ended ) {
575
    // Ensure selected text takes precedence over the word at caret position.
576
    final var selected = mTextArea.selectionProperty().getValue();
577
    final var range = selected.getLength() == 0
578
      ? getCaretWord()
579
      : selected;
580
    String text = mTextArea.getText( range );
581
582
    int length = range.getLength();
583
    text = stripStart( text, null );
584
    final int beganIndex = range.getStart() + (length - text.length());
585
586
    length = text.length();
587
    text = stripEnd( text, null );
588
    final int endedIndex = range.getEnd() - (length - text.length());
589
590
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
591
  }
592
593
  /**
594
   * Inserts the given block-level markup at the current caret position
595
   * within the document. This will prepend two blank lines to ensure that
596
   * the block element begins at the start of a new line.
597
   *
598
   * @param markup The text to insert at the caret.
599
   */
600
  private void block( final String markup ) {
601
    final int pos = mTextArea.getCaretPosition();
602
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
603
  }
604
605
  /**
606
   * Returns the caret position within the current paragraph.
607
   *
608
   * @return A value from 0 to the length of the current paragraph.
609
   */
610
  private int getCaretColumn() {
611
    return mTextArea.getCaretColumn();
612
  }
613
614
  @Override
615
  public IndexRange getCaretWord() {
616
    final var paragraph = getCaretParagraph();
617
    final var length = paragraph.length();
618
    final var column = getCaretColumn();
619
620
    var began = column;
621
    var ended = column;
622
623
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
624
      began--;
625
    }
626
627
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
628
      ended++;
629
    }
630
631
    final var iterator = BreakIterator.getWordInstance();
632
    iterator.setText( paragraph );
633
634
    while( began < length && iterator.isBoundary( began + 1 ) ) {
635
      began++;
636
    }
637
638
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
639
      ended--;
640
    }
641
642
    final var offset = getCaretDocumentOffset( column );
643
644
    return IndexRange.normalize( began + offset, ended + offset );
645
  }
646
647
  private int getCaretDocumentOffset( final int column ) {
648
    return mTextArea.getCaretPosition() - column;
649
  }
650
651
  /**
652
   * Returns the index of the paragraph where the caret resides.
653
   *
654
   * @return A number greater than or equal to 0.
655
   */
656
  private int getCurrentParagraph() {
657
    return mTextArea.getCurrentParagraph();
658
  }
659
660
  /**
661
   * Returns the text for the paragraph that contains the caret.
662
   *
663
   * @return A non-null string, possibly empty.
664
   */
665
  private String getCaretParagraph() {
666
    return getText( getCurrentParagraph() );
667
  }
668
669
  @Override
670
  public String getText( final int paragraph ) {
671
    return mTextArea.getText( paragraph );
672
  }
673
674
  @Override
675
  public String getText( final IndexRange indexes )
676
    throws IndexOutOfBoundsException {
677
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
678
  }
679
680
  @Override
681
  public void replaceText( final IndexRange indexes, final String s ) {
682
    mTextArea.replaceText( indexes, s );
683
  }
684
685
  private UndoManager<?> getUndoManager() {
686
    return mTextArea.getUndoManager();
687
  }
688
689
  /**
690
   * Returns the path to a {@link Locale}-specific stylesheet.
691
   *
692
   * @return A non-null string to inject into the HTML document head.
693
   */
694
  private static String getStylesheetPath( final Locale locale ) {
695
    return get(
696
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
697
      locale.getLanguage(),
698
      locale.getScript(),
699
      locale.getCountry()
700
    );
701
  }
702
703
  private Locale getLocale() {
704
    return localeProperty().toLocale();
705
  }
706
707
  private LocaleProperty localeProperty() {
708
    return mWorkspace.localeProperty( KEY_LANG_LOCALE );
6
import com.keenwrite.MainApp;
7
import com.keenwrite.editors.TextEditor;
8
import com.keenwrite.preferences.LocaleProperty;
9
import com.keenwrite.preferences.Workspace;
10
import com.keenwrite.spelling.impl.TextEditorSpeller;
11
import javafx.beans.binding.Bindings;
12
import javafx.beans.property.*;
13
import javafx.beans.value.ChangeListener;
14
import javafx.event.Event;
15
import javafx.scene.Node;
16
import javafx.scene.control.IndexRange;
17
import javafx.scene.input.KeyEvent;
18
import javafx.scene.layout.BorderPane;
19
import org.fxmisc.flowless.VirtualizedScrollPane;
20
import org.fxmisc.richtext.StyleClassedTextArea;
21
import org.fxmisc.richtext.model.StyleSpans;
22
import org.fxmisc.undo.UndoManager;
23
import org.fxmisc.wellbehaved.event.EventPattern;
24
import org.fxmisc.wellbehaved.event.Nodes;
25
26
import java.io.File;
27
import java.nio.charset.Charset;
28
import java.text.BreakIterator;
29
import java.util.*;
30
import java.util.function.Consumer;
31
import java.util.function.Supplier;
32
import java.util.regex.Pattern;
33
34
import static com.keenwrite.Constants.*;
35
import static com.keenwrite.MainApp.keyDown;
36
import static com.keenwrite.Messages.get;
37
import static com.keenwrite.StatusNotifier.clue;
38
import static com.keenwrite.preferences.WorkspaceKeys.*;
39
import static java.lang.Character.isWhitespace;
40
import static java.lang.String.format;
41
import static java.util.Collections.singletonList;
42
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
43
import static javafx.scene.input.KeyCode.*;
44
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
45
import static javafx.scene.input.KeyCombination.SHIFT_DOWN;
46
import static org.apache.commons.lang3.StringUtils.stripEnd;
47
import static org.apache.commons.lang3.StringUtils.stripStart;
48
import static org.fxmisc.richtext.model.StyleSpans.singleton;
49
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
50
import static org.fxmisc.wellbehaved.event.InputMap.consume;
51
52
/**
53
 * Responsible for editing Markdown documents.
54
 */
55
public final class MarkdownEditor extends BorderPane implements TextEditor {
56
  /**
57
   * Regular expression that matches the type of markup block. This is used
58
   * when Enter is pressed to continue the block environment.
59
   */
60
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
61
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
62
63
  /**
64
   * The text editor.
65
   */
66
  private final StyleClassedTextArea mTextArea =
67
    new StyleClassedTextArea( false );
68
69
  /**
70
   * Wraps the text editor in scrollbars.
71
   */
72
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
73
    new VirtualizedScrollPane<>( mTextArea );
74
75
  private final Workspace mWorkspace;
76
77
  /**
78
   * Tracks where the caret is located in this document. This offers observable
79
   * properties for caret position changes.
80
   */
81
  private final Caret mCaret = createCaret( mTextArea );
82
83
  /**
84
   * File being edited by this editor instance.
85
   */
86
  private File mFile;
87
88
  /**
89
   * Set to {@code true} upon text or caret position changes. Value is {@code
90
   * false} by default.
91
   */
92
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
93
94
  /**
95
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
96
   * either no encoding could be determined or this is a new (empty) file.
97
   */
98
  private final Charset mEncoding;
99
100
  /**
101
   * Tracks whether the in-memory definitions have changed with respect to the
102
   * persisted definitions.
103
   */
104
  private final BooleanProperty mModified = new SimpleBooleanProperty();
105
106
  public MarkdownEditor( final Workspace workspace ) {
107
    this( DOCUMENT_DEFAULT, workspace );
108
  }
109
110
  public MarkdownEditor( final File file, final Workspace workspace ) {
111
    mEncoding = open( mFile = file );
112
    mWorkspace = workspace;
113
114
    initTextArea( mTextArea );
115
    initStyle( mTextArea );
116
    initScrollPane( mScrollPane );
117
    initSpellchecker( mTextArea );
118
    initHotKeys();
119
    initUndoManager();
120
  }
121
122
  private void initTextArea( final StyleClassedTextArea textArea ) {
123
    textArea.setWrapText( true );
124
    textArea.requestFollowCaret();
125
    textArea.moveTo( 0 );
126
127
    textArea.textProperty().addListener( ( c, o, n ) -> {
128
      // Fire, regardless of whether the caret position has changed.
129
      mDirty.set( false );
130
131
      // Prevent a caret position change from raising the dirty bits.
132
      mDirty.set( true );
133
    } );
134
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
135
      // Fire when the caret position has changed and the text has not.
136
      mDirty.set( true );
137
      mDirty.set( false );
138
    } );
139
  }
140
141
  private void initStyle( final StyleClassedTextArea textArea ) {
142
    textArea.getStyleClass().add( "markdown" );
143
144
    final var stylesheets = textArea.getStylesheets();
145
    stylesheets.add( getStylesheetPath( getLocale() ) );
146
147
    localeProperty().addListener( ( c, o, n ) -> {
148
      if( n != null ) {
149
        stylesheets.clear();
150
        stylesheets.add( getStylesheetPath( getLocale() ) );
151
      }
152
    } );
153
154
    fontNameProperty().addListener(
155
      ( c, o, n ) ->
156
        mTextArea.setStyle( format( "-fx-font-family: '%s';", getFontName() ) )
157
    );
158
159
    fontSizeProperty().addListener(
160
      ( c, o, n ) ->
161
        mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) )
162
    );
163
  }
164
165
  private void initScrollPane(
166
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
167
    scrollpane.setVbarPolicy( ALWAYS );
168
    setCenter( scrollpane );
169
  }
170
171
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
172
    final var speller = new TextEditorSpeller();
173
    speller.checkDocument( textarea );
174
    speller.checkParagraphs( textarea );
175
  }
176
177
  private void initHotKeys() {
178
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
179
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
180
    addEventListener( keyPressed( TAB ), this::tab );
181
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
182
    addEventListener( keyPressed( INSERT ), this::onInsertPressed );
183
  }
184
185
  private void initUndoManager() {
186
    final var undoManager = getUndoManager();
187
    final var markedPosition = undoManager.atMarkedPositionProperty();
188
189
    undoManager.forgetHistory();
190
    undoManager.mark();
191
    mModified.bind( Bindings.not( markedPosition ) );
192
  }
193
194
  @Override
195
  public void moveTo( final int offset ) {
196
    assert 0 <= offset && offset <= mTextArea.getLength();
197
    mTextArea.moveTo( offset );
198
    mTextArea.requestFollowCaret();
199
  }
200
201
  /**
202
   * Delegate the focus request to the text area itself.
203
   */
204
  @Override
205
  public void requestFocus() {
206
    mTextArea.requestFocus();
207
  }
208
209
  @Override
210
  public void setText( final String text ) {
211
    mTextArea.clear();
212
    mTextArea.appendText( text );
213
    mTextArea.getUndoManager().mark();
214
  }
215
216
  @Override
217
  public String getText() {
218
    return mTextArea.getText();
219
  }
220
221
  @Override
222
  public Charset getEncoding() {
223
    return mEncoding;
224
  }
225
226
  @Override
227
  public File getFile() {
228
    return mFile;
229
  }
230
231
  @Override
232
  public void rename( final File file ) {
233
    mFile = file;
234
  }
235
236
  @Override
237
  public void undo() {
238
    final var manager = getUndoManager();
239
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
240
  }
241
242
  @Override
243
  public void redo() {
244
    final var manager = getUndoManager();
245
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
246
  }
247
248
  /**
249
   * Performs an undo or redo action, if possible, otherwise displays an error
250
   * message to the user.
251
   *
252
   * @param ready  Answers whether the action can be executed.
253
   * @param action The action to execute.
254
   * @param key    The informational message key having a value to display if
255
   *               the {@link Supplier} is not ready.
256
   */
257
  private void xxdo(
258
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
259
    if( ready.get() ) {
260
      action.run();
261
    }
262
    else {
263
      clue( key );
264
    }
265
  }
266
267
  @Override
268
  public void cut() {
269
    final var selected = mTextArea.getSelectedText();
270
271
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
272
    if( selected == null || selected.isEmpty() ) {
273
      // Note: mTextArea.selectLine() does not select empty lines.
274
      mTextArea.fireEvent( keyDown( HOME, false ) );
275
      mTextArea.fireEvent( keyDown( DOWN, true ) );
276
    }
277
278
    mTextArea.cut();
279
  }
280
281
  @Override
282
  public void copy() {
283
    mTextArea.copy();
284
  }
285
286
  @Override
287
  public void paste() {
288
    mTextArea.paste();
289
  }
290
291
  @Override
292
  public void selectAll() {
293
    mTextArea.selectAll();
294
  }
295
296
  @Override
297
  public void bold() {
298
    enwrap( "**" );
299
  }
300
301
  @Override
302
  public void italic() {
303
    enwrap( "*" );
304
  }
305
306
  @Override
307
  public void superscript() {
308
    enwrap( "^" );
309
  }
310
311
  @Override
312
  public void subscript() {
313
    enwrap( "~" );
314
  }
315
316
  @Override
317
  public void strikethrough() {
318
    enwrap( "~~" );
319
  }
320
321
  @Override
322
  public void blockquote() {
323
    block( "> " );
324
  }
325
326
  @Override
327
  public void code() {
328
    enwrap( "`" );
329
  }
330
331
  @Override
332
  public void fencedCodeBlock() {
333
    enwrap( "\n\n```\n", "\n```\n\n" );
334
  }
335
336
  @Override
337
  public void heading( final int level ) {
338
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
339
    block( format( "%s ", hashes ) );
340
  }
341
342
  @Override
343
  public void unorderedList() {
344
    block( "* " );
345
  }
346
347
  @Override
348
  public void orderedList() {
349
    block( "1. " );
350
  }
351
352
  @Override
353
  public void horizontalRule() {
354
    block( format( "---%n%n" ) );
355
  }
356
357
  @Override
358
  public Node getNode() {
359
    return this;
360
  }
361
362
  @Override
363
  public ReadOnlyBooleanProperty modifiedProperty() {
364
    return mModified;
365
  }
366
367
  @Override
368
  public void clearModifiedProperty() {
369
    getUndoManager().mark();
370
  }
371
372
  @Override
373
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
374
    return mScrollPane;
375
  }
376
377
  @Override
378
  public StyleClassedTextArea getTextArea() {
379
    return mTextArea;
380
  }
381
382
  private final Map<String, IndexRange> mStyles = new HashMap<>();
383
384
  @Override
385
  public void stylize( final IndexRange range, final String style ) {
386
    final var began = range.getStart();
387
    final var ended = range.getEnd() + 1;
388
389
    assert 0 <= began && began <= ended;
390
    assert style != null;
391
392
    // TODO: Ensure spell check and find highlights can coexist.
393
//    final var spans = mTextArea.getStyleSpans( range );
394
//    System.out.println( "SPANS: " + spans );
395
396
//    final var spans = mTextArea.getStyleSpans( range );
397
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
398
//    ) );
399
400
//    final var builder = new StyleSpansBuilder<Collection<String>>();
401
//    builder.add( singleton( style ), range.getLength() + 1 );
402
//    mTextArea.setStyleSpans( began, builder.create() );
403
404
//    final var s = mTextArea.getStyleSpans( began, ended );
405
//    System.out.println( "STYLES: " +s );
406
407
    mStyles.put( style, range );
408
    mTextArea.setStyleClass( began, ended, style );
409
410
    // Ensure that whenever the user interacts with the text that the found
411
    // word will have its highlighting removed. The handler removes itself.
412
    // This won't remove the highlighting if the caret position moves by mouse.
413
    final var handler = mTextArea.getOnKeyPressed();
414
    mTextArea.setOnKeyPressed( ( event ) -> {
415
      mTextArea.setOnKeyPressed( handler );
416
      unstylize( style );
417
    } );
418
419
    //mTextArea.setStyleSpans(began, ended, s);
420
  }
421
422
  private static StyleSpans<Collection<String>> merge(
423
    StyleSpans<Collection<String>> spans, int len, String style ) {
424
    spans = spans.overlay(
425
      singleton( singletonList( style ), len ),
426
      ( bottomSpan, list ) -> {
427
        final List<String> l =
428
          new ArrayList<>( bottomSpan.size() + list.size() );
429
        l.addAll( bottomSpan );
430
        l.addAll( list );
431
        return l;
432
      } );
433
434
    return spans;
435
  }
436
437
  @Override
438
  public void unstylize( final String style ) {
439
    final var indexes = mStyles.remove( style );
440
    if( indexes != null ) {
441
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
442
    }
443
  }
444
445
  @Override
446
  public Caret getCaret() {
447
    return mCaret;
448
  }
449
450
  private Caret createCaret( final StyleClassedTextArea editor ) {
451
    return Caret
452
      .builder()
453
      .with( Caret.Mutator::setEditor, editor )
454
      .build();
455
  }
456
457
  /**
458
   * This method adds listeners to editor events.
459
   *
460
   * @param <T>      The event type.
461
   * @param <U>      The consumer type for the given event type.
462
   * @param event    The event of interest.
463
   * @param consumer The method to call when the event happens.
464
   */
465
  public <T extends Event, U extends T> void addEventListener(
466
    final EventPattern<? super T, ? extends U> event,
467
    final Consumer<? super U> consumer ) {
468
    Nodes.addInputMap( mTextArea, consume( event, consumer ) );
469
  }
470
471
  private void onEnterPressed( final KeyEvent ignored ) {
472
    final var currentLine = getCaretParagraph();
473
    final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine );
474
475
    // By default, insert a new line by itself.
476
    String newText = NEWLINE;
477
478
    // If the pattern was matched then determine what block type to continue.
479
    if( matcher.matches() ) {
480
      if( matcher.group( 2 ).isEmpty() ) {
481
        final var pos = mTextArea.getCaretPosition();
482
        mTextArea.selectRange( pos - currentLine.length(), pos );
483
      }
484
      else {
485
        // Indent the new line with the same whitespace characters and
486
        // list markers as current line. This ensures that the indentation
487
        // is propagated.
488
        newText = newText.concat( matcher.group( 1 ) );
489
      }
490
    }
491
492
    mTextArea.replaceSelection( newText );
493
  }
494
495
  /**
496
   * TODO: 105 - Insert key toggle overwrite (typeover) mode
497
   *
498
   * @param ignored Unused.
499
   */
500
  private void onInsertPressed( final KeyEvent ignored ) {
501
  }
502
503
  private void cut( final KeyEvent event ) {
504
    cut();
505
  }
506
507
  private void tab( final KeyEvent event ) {
508
    final var range = mTextArea.selectionProperty().getValue();
509
    final var sb = new StringBuilder( 1024 );
510
511
    if( range.getLength() > 0 ) {
512
      final var selection = mTextArea.getSelectedText();
513
514
      selection.lines().forEach(
515
        ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE )
516
      );
517
    }
518
    else {
519
      sb.append( "\t" );
520
    }
521
522
    mTextArea.replaceSelection( sb.toString() );
523
  }
524
525
  private void untab( final KeyEvent event ) {
526
    final var range = mTextArea.selectionProperty().getValue();
527
528
    if( range.getLength() > 0 ) {
529
      final var selection = mTextArea.getSelectedText();
530
      final var sb = new StringBuilder( selection.length() );
531
532
      selection.lines().forEach(
533
        ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l )
534
                   .append( NEWLINE )
535
      );
536
537
      mTextArea.replaceSelection( sb.toString() );
538
    }
539
    else {
540
      final var p = getCaretParagraph();
541
542
      if( p.startsWith( "\t" ) ) {
543
        mTextArea.selectParagraph();
544
        mTextArea.replaceSelection( p.substring( 1 ) );
545
      }
546
    }
547
  }
548
549
  /**
550
   * Observers may listen for changes to the property returned from this method
551
   * to receive notifications when either the text or caret have changed. This
552
   * should not be used to track whether the text has been modified.
553
   */
554
  public void addDirtyListener( ChangeListener<Boolean> listener ) {
555
    mDirty.addListener( listener );
556
  }
557
558
  /**
559
   * Surrounds the selected text or word under the caret in Markdown markup.
560
   *
561
   * @param token The beginning and ending token for enclosing the text.
562
   */
563
  private void enwrap( final String token ) {
564
    enwrap( token, token );
565
  }
566
567
  /**
568
   * Surrounds the selected text or word under the caret in Markdown markup.
569
   *
570
   * @param began The beginning token for enclosing the text.
571
   * @param ended The ending token for enclosing the text.
572
   */
573
  private void enwrap( final String began, String ended ) {
574
    // Ensure selected text takes precedence over the word at caret position.
575
    final var selected = mTextArea.selectionProperty().getValue();
576
    final var range = selected.getLength() == 0
577
      ? getCaretWord()
578
      : selected;
579
    String text = mTextArea.getText( range );
580
581
    int length = range.getLength();
582
    text = stripStart( text, null );
583
    final int beganIndex = range.getStart() + (length - text.length());
584
585
    length = text.length();
586
    text = stripEnd( text, null );
587
    final int endedIndex = range.getEnd() - (length - text.length());
588
589
    mTextArea.replaceText( beganIndex, endedIndex, began + text + ended );
590
  }
591
592
  /**
593
   * Inserts the given block-level markup at the current caret position
594
   * within the document. This will prepend two blank lines to ensure that
595
   * the block element begins at the start of a new line.
596
   *
597
   * @param markup The text to insert at the caret.
598
   */
599
  private void block( final String markup ) {
600
    final int pos = mTextArea.getCaretPosition();
601
    mTextArea.insertText( pos, format( "%n%n%s", markup ) );
602
  }
603
604
  /**
605
   * Returns the caret position within the current paragraph.
606
   *
607
   * @return A value from 0 to the length of the current paragraph.
608
   */
609
  private int getCaretColumn() {
610
    return mTextArea.getCaretColumn();
611
  }
612
613
  @Override
614
  public IndexRange getCaretWord() {
615
    final var paragraph = getCaretParagraph();
616
    final var length = paragraph.length();
617
    final var column = getCaretColumn();
618
619
    var began = column;
620
    var ended = column;
621
622
    while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) {
623
      began--;
624
    }
625
626
    while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) {
627
      ended++;
628
    }
629
630
    final var iterator = BreakIterator.getWordInstance();
631
    iterator.setText( paragraph );
632
633
    while( began < length && iterator.isBoundary( began + 1 ) ) {
634
      began++;
635
    }
636
637
    while( ended > 0 && iterator.isBoundary( ended - 1 ) ) {
638
      ended--;
639
    }
640
641
    final var offset = getCaretDocumentOffset( column );
642
643
    return IndexRange.normalize( began + offset, ended + offset );
644
  }
645
646
  private int getCaretDocumentOffset( final int column ) {
647
    return mTextArea.getCaretPosition() - column;
648
  }
649
650
  /**
651
   * Returns the index of the paragraph where the caret resides.
652
   *
653
   * @return A number greater than or equal to 0.
654
   */
655
  private int getCurrentParagraph() {
656
    return mTextArea.getCurrentParagraph();
657
  }
658
659
  /**
660
   * Returns the text for the paragraph that contains the caret.
661
   *
662
   * @return A non-null string, possibly empty.
663
   */
664
  private String getCaretParagraph() {
665
    return getText( getCurrentParagraph() );
666
  }
667
668
  @Override
669
  public String getText( final int paragraph ) {
670
    return mTextArea.getText( paragraph );
671
  }
672
673
  @Override
674
  public String getText( final IndexRange indexes )
675
    throws IndexOutOfBoundsException {
676
    return mTextArea.getText( indexes.getStart(), indexes.getEnd() );
677
  }
678
679
  @Override
680
  public void replaceText( final IndexRange indexes, final String s ) {
681
    mTextArea.replaceText( indexes, s );
682
  }
683
684
  private UndoManager<?> getUndoManager() {
685
    return mTextArea.getUndoManager();
686
  }
687
688
  /**
689
   * Returns the path to a {@link Locale}-specific stylesheet.
690
   *
691
   * @return A non-null string to inject into the HTML document head.
692
   */
693
  private static String getStylesheetPath( final Locale locale ) {
694
    return get(
695
      sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ),
696
      locale.getLanguage(),
697
      locale.getScript(),
698
      locale.getCountry()
699
    );
700
  }
701
702
  private Locale getLocale() {
703
    return localeProperty().toLocale();
704
  }
705
706
  private LocaleProperty localeProperty() {
707
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
709708
  }
710709
A src/main/java/com/keenwrite/io/FileEvent.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import java.io.File;
5
import java.util.EventObject;
6
7
/**
8
 * Responsible for indicating that a file has been modified by the file system.
9
 */
10
public class FileEvent extends EventObject {
11
12
  /**
13
   * Constructs a new event that indicates the source of a file system event.
14
   *
15
   * @param file The {@link File} that has succumb to a file system event.
16
   */
17
  public FileEvent( final File file ) {
18
    super( file );
19
  }
20
21
  /**
22
   * Returns the source as an instance of {@link File}.
23
   *
24
   * @return The {@link File} being watched.
25
   */
26
  public File getFile() {
27
    return (File) getSource();
28
  }
29
}
130
A src/main/java/com/keenwrite/io/FileModifiedListener.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import java.util.EventListener;
5
import java.util.function.Consumer;
6
7
/**
8
 * Responsible for informing listeners when a file has been modified.
9
 */
10
public interface FileModifiedListener
11
  extends EventListener, Consumer<FileEvent> {
12
}
113
A src/main/java/com/keenwrite/io/FileWatchService.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import org.renjin.repackaged.guava.collect.BiMap;
5
import org.renjin.repackaged.guava.collect.HashBiMap;
6
7
import java.io.File;
8
import java.io.IOException;
9
import java.nio.file.FileSystems;
10
import java.nio.file.Path;
11
import java.nio.file.WatchKey;
12
import java.nio.file.WatchService;
13
import java.util.Set;
14
import java.util.concurrent.ConcurrentHashMap;
15
16
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
17
import static java.util.Collections.newSetFromMap;
18
19
/**
20
 * Responsible for watching when a file has been changed.
21
 */
22
public class FileWatchService implements Runnable {
23
  /**
24
   * Set to {@code false} when {@link #stop()} is called.
25
   */
26
  private volatile boolean mRunning;
27
28
  /**
29
   * Contains the listeners to notify when a given file has changed.
30
   */
31
  private final Set<FileModifiedListener> mListeners =
32
    newSetFromMap( new ConcurrentHashMap<>() );
33
  private final WatchService mWatchService;
34
  private final BiMap<File, WatchKey> mWatched = HashBiMap.create();
35
36
  /**
37
   * Creates a new file system watch service with the given files to watch.
38
   *
39
   * @param files The files to watch for file system events.
40
   */
41
  public FileWatchService( final File... files ) {
42
    WatchService watchService;
43
44
    try {
45
      watchService = FileSystems.getDefault().newWatchService();
46
47
      for( final var file : files ) {
48
        register( file );
49
      }
50
    } catch( final IOException ex ) {
51
      // Create a fallback that allows the class to be instantiated and used
52
      // without without preventing the application from launching.
53
      watchService = new PollingWatchService();
54
    }
55
56
    mWatchService = watchService;
57
  }
58
59
  /**
60
   * Runs the event handler until {@link #stop()} is called.
61
   *
62
   * @throws RuntimeException There was an error watching for file events.
63
   */
64
  @Override
65
  public void run() {
66
    mRunning = true;
67
68
    while( mRunning ) {
69
      handleEvents();
70
    }
71
  }
72
73
  private void handleEvents() {
74
    try {
75
      final var watchKey = mWatchService.take();
76
77
      for( final var pollEvent : watchKey.pollEvents() ) {
78
        final var watchable = (Path) watchKey.watchable();
79
        final var context = (Path) pollEvent.context();
80
        final var file = watchable.resolve( context ).toFile();
81
82
        if( mWatched.containsKey( file ) ) {
83
          final var fileEvent = new FileEvent( file );
84
85
          for( final var listener : mListeners ) {
86
            listener.accept( fileEvent );
87
          }
88
        }
89
      }
90
91
      if( !watchKey.reset() ) {
92
        unregister( watchKey );
93
      }
94
    } catch( final Exception ex ) {
95
      throw new RuntimeException( ex );
96
    }
97
  }
98
99
  /**
100
   * Adds the given {@link File}'s containing directory to the watch list. When
101
   * the given {@link File} is modified, this service will receive a
102
   * notification that the containing directory has been modified, which will
103
   * then be filtered by file name.
104
   * <p>
105
   * This method is idempotent.
106
   * </p>
107
   *
108
   * @param file The {@link File} to watch for modification events.
109
   * @return The {@link File}'s directory watch state.
110
   * @throws IOException              Could not register the directory.
111
   * @throws IllegalArgumentException The {@link File} has no parent directory.
112
   */
113
  public WatchKey register( final File file ) throws IOException {
114
    if( mWatched.containsKey( file ) ) {
115
      return mWatched.get( file );
116
    }
117
118
    final var path = getParentDirectory( file );
119
    final var watchKey = path.register( mWatchService, ENTRY_MODIFY );
120
121
    return mWatched.put( file, watchKey );
122
  }
123
124
  /**
125
   * Removes the given {@link File}'s containing directory from the watch list.
126
   * <p>
127
   * This method is idempotent.
128
   * </p>
129
   *
130
   * @param file The {@link File} to no longer watch.
131
   * @throws IllegalArgumentException The {@link File} has no parent directory.
132
   */
133
  public void unregister( final File file ) {
134
    mWatched.remove( cancel( file ) );
135
  }
136
137
  /**
138
   * Cancels watching the given file for file system changes.
139
   *
140
   * @param file The {@link File} to watch for file events.
141
   * @return The given file, always.
142
   */
143
  private File cancel( final File file ) {
144
    final var watchKey = mWatched.get( file );
145
146
    if( watchKey != null ) {
147
      watchKey.cancel();
148
    }
149
150
    return file;
151
  }
152
153
  /**
154
   * Removes the given {@link WatchKey} from the registration map.
155
   *
156
   * @param watchKey The {@link WatchKey} to remove from the map.
157
   */
158
  private void unregister( final WatchKey watchKey ) {
159
    unregister( mWatched.inverse().get( watchKey ) );
160
  }
161
162
  /**
163
   * Adds a listener to be notified when a file under watch has been modified.
164
   * Listeners are backed by a set.
165
   *
166
   * @param listener The {@link FileModifiedListener} to add to the list.
167
   * @return {@code true} if this set did not already contain listener.
168
   */
169
  public boolean addListener( final FileModifiedListener listener ) {
170
    return mListeners.add( listener );
171
  }
172
173
  /**
174
   * Removes a listener from the notify list.
175
   *
176
   * @param listener The {@link FileModifiedListener} to remove.
177
   * @return {@code true} if this contained the given listener.
178
   */
179
  public boolean removeListener( final FileModifiedListener listener ) {
180
    return mListeners.remove( listener );
181
  }
182
183
  /**
184
   * Shuts down the file watch service and clears both watchers and listeners.
185
   *
186
   * @throws IOException Could not close the watch service.
187
   */
188
  public void stop() throws IOException {
189
    mRunning = false;
190
191
    for( final var file : mWatched.keySet() ) {
192
      cancel( file );
193
    }
194
195
    mWatched.clear();
196
    mListeners.clear();
197
    mWatchService.close();
198
  }
199
200
  /**
201
   * Returns the directory containing the given {@link File} instance.
202
   *
203
   * @param file The {@link File}'s containing directory to watch.
204
   * @return The {@link Path} to the {@link File}'s directory.
205
   * @throws IllegalArgumentException The {@link File} has no parent directory.
206
   */
207
  private Path getParentDirectory( final File file ) {
208
    assert file != null;
209
    assert !file.isDirectory();
210
211
    final var directory = file.getParentFile();
212
213
    if( directory == null ) {
214
      throw new IllegalArgumentException( file.getAbsolutePath() );
215
    }
216
217
    return directory.toPath();
218
  }
219
}
1220
A src/main/java/com/keenwrite/io/PollingWatchService.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import java.nio.file.WatchEvent;
5
import java.nio.file.WatchKey;
6
import java.nio.file.WatchService;
7
import java.nio.file.Watchable;
8
import java.util.List;
9
import java.util.concurrent.TimeUnit;
10
11
/**
12
 * Responsible for polling the file system to see whether a file has been
13
 * updated. This is instantiated when an instance of {@link WatchService}
14
 * cannot be created using the Java API.
15
 * <p>
16
 * This is a skeleton class to avoid {@code null} references. In theory,
17
 * it should never get instantiated. If the application is run on a system
18
 * that does not support file system events, this should eliminate NPEs.
19
 * </p>
20
 */
21
public class PollingWatchService implements WatchService {
22
  private final WatchKey EMPTY_KEY = new WatchKey() {
23
    private final Watchable WATCHABLE = new Watchable() {
24
      @Override
25
      public WatchKey register(
26
        final WatchService watcher,
27
        final WatchEvent.Kind<?>[] events,
28
        final WatchEvent.Modifier... modifiers ) {
29
        return EMPTY_KEY;
30
      }
31
32
      @Override
33
      public WatchKey register(
34
        final WatchService watcher, final WatchEvent.Kind<?>... events ) {
35
        return EMPTY_KEY;
36
      }
37
    };
38
39
    @Override
40
    public boolean isValid() {
41
      return false;
42
    }
43
44
    @Override
45
    public List<WatchEvent<?>> pollEvents() {
46
      return List.of();
47
    }
48
49
    @Override
50
    public boolean reset() {
51
      return false;
52
    }
53
54
    @Override
55
    public void cancel() {
56
    }
57
58
    @Override
59
    public Watchable watchable() {
60
      return WATCHABLE;
61
    }
62
  };
63
64
  @Override
65
  public void close() {
66
  }
67
68
  @Override
69
  public WatchKey poll() {
70
    return EMPTY_KEY;
71
  }
72
73
  @Override
74
  public WatchKey poll( final long timeout, final TimeUnit unit ) {
75
    return EMPTY_KEY;
76
  }
77
78
  @Override
79
  public WatchKey take() {
80
    return EMPTY_KEY;
81
  }
82
}
183
M src/main/java/com/keenwrite/preferences/LocaleProperty.java
1414
import static java.util.Locale.forLanguageTag;
1515
16
/**
17
 * Responsible for providing a list of locales from which the user may pick.
18
 */
1619
public final class LocaleProperty extends SimpleObjectProperty<String> {
1720
...
7477
7578
  private static Locale sanitize( final Locale locale ) {
79
    // If the language is "und"efined then use the default locale.
7680
    return locale == null || "und".equalsIgnoreCase( locale.toLanguageTag() )
7781
      ? LOCALE_DEFAULT
M src/main/java/com/keenwrite/preferences/PreferencesController.java
2626
import static com.keenwrite.Messages.get;
2727
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
28
import static com.keenwrite.preferences.Workspace.*;
28
import static com.keenwrite.preferences.ThemeProperty.themeListProperty;
29
import static com.keenwrite.preferences.WorkspaceKeys.*;
2930
import static javafx.scene.control.ButtonType.CANCEL;
3031
import static javafx.scene.control.ButtonType.OK;
...
196197
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
197198
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
199
        )
200
      ),
201
      Category.of(
202
        get( KEY_UI_THEME ),
203
        Group.of(
204
          get( KEY_UI_THEME_SELECTION ),
205
          Setting.of( label( KEY_UI_THEME_SELECTION ) ),
206
          Setting.of( title( KEY_UI_THEME_SELECTION ),
207
                      themeListProperty(),
208
                      themeProperty( KEY_UI_THEME_SELECTION ) )
209
        ),
210
        Group.of(
211
          get( KEY_UI_THEME_CUSTOM ),
212
          Setting.of( label( KEY_UI_THEME_CUSTOM ) ),
213
          Setting.of( title( KEY_UI_THEME_CUSTOM ),
214
                      fileProperty( KEY_UI_THEME_CUSTOM ), false )
198215
        )
199216
      ),
200217
      Category.of(
201218
        get( KEY_LANGUAGE ),
202219
        Group.of(
203
          get( KEY_LANG_LOCALE ),
204
          Setting.of( label( KEY_LANG_LOCALE ) ),
205
          Setting.of( title( KEY_LANG_LOCALE ),
220
          get( KEY_LANGUAGE_LOCALE ),
221
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
222
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
206223
                      localeListProperty(),
207
                      localeProperty( KEY_LANG_LOCALE ) )
224
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
208225
        )
209226
      )
...
265282
  private DoubleProperty doubleProperty( final Key key ) {
266283
    return mWorkspace.doubleProperty( key );
284
  }
285
286
  private ObjectProperty<String> themeProperty( final Key key ) {
287
    return mWorkspace.themeProperty( key );
267288
  }
268289
A src/main/java/com/keenwrite/preferences/ThemeProperty.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import com.keenwrite.Constants;
5
import javafx.beans.property.SimpleObjectProperty;
6
import javafx.collections.ObservableList;
7
8
import java.util.LinkedHashSet;
9
import java.util.Set;
10
11
import static com.keenwrite.Constants.THEME_DEFAULT;
12
import static com.keenwrite.preferences.Workspace.listProperty;
13
14
/**
15
 * Responsible for providing a list of themes from which the user may pick.
16
 */
17
public final class ThemeProperty extends SimpleObjectProperty<String> {
18
  /**
19
   * Ordered set of available themes.
20
   */
21
  private static final Set<String> sThemes = new LinkedHashSet<>();
22
23
  static {
24
    sThemes.add( "Count Darcula" );
25
    sThemes.add( "Haunted Grey" );
26
    sThemes.add( "Modena Dark" );
27
    sThemes.add( THEME_DEFAULT );
28
    sThemes.add( "Silver Cavern" );
29
    sThemes.add( "Solarized Dark" );
30
    sThemes.add( "Vampire Byte" );
31
  }
32
33
  public ThemeProperty( final String themeName ) {
34
    super( themeName );
35
  }
36
37
  public static ObservableList<String> themeListProperty() {
38
    return listProperty( sThemes );
39
  }
40
41
  /**
42
   * Returns the given theme name as a sanitized file name, which must map
43
   * to a stylesheet file bundled with the application. This does not include
44
   * the path to the stylesheet. If the given theme name cannot be found in
45
   * the known theme list, the file name for {@link Constants#THEME_DEFAULT}
46
   * is returned. The extension must be added separately.
47
   *
48
   * @param theme The name to convert to a file name.
49
   * @return The given theme name converted lower case, spaces replaced with
50
   * underscores, without the ".css" extension appended.
51
   */
52
  public static String toFilename( final String theme ) {
53
    return sanitize( theme ).toLowerCase().replace( ' ', '_' );
54
  }
55
56
  /**
57
   * Ensures that the given theme name is in the list of known themes.
58
   *
59
   * @param theme Validate this theme name's existence.
60
   * @return The given theme name, if valid, otherwise the default theme name.
61
   */
62
  private static String sanitize( final String theme ) {
63
    return sThemes.contains( theme ) ? theme : THEME_DEFAULT;
64
  }
65
}
166
M src/main/java/com/keenwrite/preferences/Workspace.java
2222
import static com.keenwrite.Launcher.getVersion;
2323
import static com.keenwrite.StatusNotifier.clue;
24
import static com.keenwrite.preferences.Key.key;
24
import static com.keenwrite.preferences.WorkspaceKeys.*;
2525
import static java.util.Map.entry;
2626
import static javafx.application.Platform.runLater;
...
6161
 */
6262
public final class Workspace {
63
  private static final Key KEY_ROOT = key( "workspace" );
64
65
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
66
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
67
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
68
69
  public static final Key KEY_R = key( KEY_ROOT, "r" );
70
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
71
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
72
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
73
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
74
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
75
76
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
77
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
78
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
79
80
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
81
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
82
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
83
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
84
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
85
8663
  //@formatter:off
87
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
88
89
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
90
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
91
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT,"document" );
92
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
93
94
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
95
  public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
96
97
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
98
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
99
  public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
100
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
101
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
102
  public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
103
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
104
  public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" );
105
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
106
  public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" );
107
108
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
109
  public static final Key KEY_LANG_LOCALE = key( KEY_LANGUAGE, "locale" );
110
111
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
112
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
113
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
114
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
115
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
116
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
117
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
118
11964
  private final Map<Key, Property<?>> VALUES = Map.ofEntries(
12065
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
...
13277
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
13378
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
134
    
79
13580
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
13681
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
13782
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
13883
    
139
    entry( KEY_LANG_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
14084
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
14185
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
...
15094
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
15195
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
152
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() )
153
  );
96
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
97
98
    entry( KEY_UI_THEME_SELECTION, asThemeProperty( THEME_DEFAULT ) ),
99
    entry( KEY_UI_THEME_CUSTOM, asFileProperty( THEME_CUSTOM_DEFAULT ) ),
100
101
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) )
102
    );
154103
  //@formatter:on
155104
...
168117
  private FileProperty asFileProperty( final File defaultValue ) {
169118
    return new FileProperty( defaultValue );
119
  }
120
121
  @SuppressWarnings( "SameParameterValue" )
122
  private ThemeProperty asThemeProperty( final String defaultValue ) {
123
    return new ThemeProperty( defaultValue );
170124
  }
171125
...
310264
   */
311265
  public ObjectProperty<File> fileProperty( final Key key ) {
266
    return valuesProperty( key );
267
  }
268
269
  public ObjectProperty<String> themeProperty( final Key key ) {
312270
    return valuesProperty( key );
313271
  }
314272
315273
  public LocaleProperty localeProperty( final Key key ) {
316274
    return valuesProperty( key );
317275
  }
318276
319277
  /**
320
   * Returns the language locale setting for the {@link #KEY_LANG_LOCALE} key.
278
   * Returns the language locale setting for the
279
   * {@link WorkspaceKeys#KEY_LANGUAGE_LOCALE} key.
321280
   *
322281
   * @return The user's current locale setting.
323282
   */
324283
  public Locale getLocale() {
325
    return localeProperty( KEY_LANG_LOCALE ).toLocale();
284
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
326285
  }
327286
A src/main/java/com/keenwrite/preferences/WorkspaceKeys.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preferences;
3
4
import static com.keenwrite.preferences.Key.key;
5
6
/**
7
 * Responsible for defining constants used throughout the application that
8
 * represent persisted preferences.
9
 */
10
public final class WorkspaceKeys {
11
  //@formatter:off
12
  private static final Key KEY_ROOT = key( "workspace" );
13
14
  public static final Key KEY_META = key( KEY_ROOT, "meta" );
15
  public static final Key KEY_META_NAME = key( KEY_META, "name" );
16
  public static final Key KEY_META_VERSION = key( KEY_META, "version" );
17
18
  public static final Key KEY_R = key( KEY_ROOT, "r" );
19
  public static final Key KEY_R_SCRIPT = key( KEY_R, "script" );
20
  public static final Key KEY_R_DIR = key( KEY_R, "dir" );
21
  public static final Key KEY_R_DELIM = key( KEY_R, "delimiter" );
22
  public static final Key KEY_R_DELIM_BEGAN = key( KEY_R_DELIM, "began" );
23
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
24
25
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
26
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
27
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
28
29
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
30
  public static final Key KEY_DEF_PATH = key( KEY_DEF, "path" );
31
  public static final Key KEY_DEF_DELIM = key( KEY_DEF, "delimiter" );
32
  public static final Key KEY_DEF_DELIM_BEGAN = key( KEY_DEF_DELIM, "began" );
33
  public static final Key KEY_DEF_DELIM_ENDED = key( KEY_DEF_DELIM, "ended" );
34
35
  public static final Key KEY_UI = key( KEY_ROOT, "ui" );
36
37
  public static final Key KEY_UI_RECENT = key( KEY_UI, "recent" );
38
  public static final Key KEY_UI_RECENT_DIR = key( KEY_UI_RECENT, "dir" );
39
  public static final Key KEY_UI_RECENT_DOCUMENT = key( KEY_UI_RECENT, "document" );
40
  public static final Key KEY_UI_RECENT_DEFINITION = key( KEY_UI_RECENT, "definition" );
41
42
  public static final Key KEY_UI_FILES = key( KEY_UI, "files" );
43
  public static final Key KEY_UI_FILES_PATH = key( KEY_UI_FILES, "path" );
44
45
  public static final Key KEY_UI_FONT = key( KEY_UI, "font" );
46
  public static final Key KEY_UI_FONT_EDITOR = key( KEY_UI_FONT, "editor" );
47
  public static final Key KEY_UI_FONT_EDITOR_NAME = key( KEY_UI_FONT_EDITOR, "name" );
48
  public static final Key KEY_UI_FONT_EDITOR_SIZE = key( KEY_UI_FONT_EDITOR, "size" );
49
  public static final Key KEY_UI_FONT_PREVIEW = key( KEY_UI_FONT, "preview" );
50
  public static final Key KEY_UI_FONT_PREVIEW_NAME = key( KEY_UI_FONT_PREVIEW, "name" );
51
  public static final Key KEY_UI_FONT_PREVIEW_SIZE = key( KEY_UI_FONT_PREVIEW, "size" );
52
  public static final Key KEY_UI_FONT_PREVIEW_MONO = key( KEY_UI_FONT_PREVIEW, "mono" );
53
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
54
  public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" );
55
56
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
57
  public static final Key KEY_UI_WINDOW_X = key( KEY_UI_WINDOW, "x" );
58
  public static final Key KEY_UI_WINDOW_Y = key( KEY_UI_WINDOW, "y" );
59
  public static final Key KEY_UI_WINDOW_W = key( KEY_UI_WINDOW, "width" );
60
  public static final Key KEY_UI_WINDOW_H = key( KEY_UI_WINDOW, "height" );
61
  public static final Key KEY_UI_WINDOW_MAX = key( KEY_UI_WINDOW, "maximized" );
62
  public static final Key KEY_UI_WINDOW_FULL = key( KEY_UI_WINDOW, "full" );
63
64
  public static final Key KEY_UI_THEME = key( KEY_UI, "theme" );
65
  public static final Key KEY_UI_THEME_SELECTION = key( KEY_UI_THEME, "selection" );
66
67
  public static final Key KEY_UI_THEME_CUSTOM = key( KEY_UI_THEME, "custom" );
68
69
//  public static final Key KEY_UI_THEME_CUSTOM = key( KEY_UI_THEME, "custom" );
70
//  public static final Key KEY_UI_THEME_CUSTOM_FONT = key( KEY_UI_THEME_CUSTOM, "font" );
71
//  public static final Key KEY_UI_THEME_CUSTOM_FONT_SIZE = key( KEY_UI_THEME_CUSTOM_FONT, "size" );
72
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS = key( KEY_UI_THEME_CUSTOM, "colours" );
73
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_BASE = key( KEY_UI_THEME_CUSTOM_COLOURS, "base" );
74
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_BG = key( KEY_UI_THEME_CUSTOM_COLOURS, "background" );
75
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_CONTROLS = key( KEY_UI_THEME_CUSTOM_COLOURS, "controls" );
76
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_ROW1 = key( KEY_UI_THEME_CUSTOM_COLOURS, "row" );
77
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_ROW2 = key( KEY_UI_THEME_CUSTOM_COLOURS, "row" );
78
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_FG = key( KEY_UI_THEME_CUSTOM_COLOURS, "foreground" );
79
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_FG_LIGHT = key( KEY_UI_THEME_CUSTOM_COLOURS_FG, "light" );
80
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_FG_MEDIUM = key( KEY_UI_THEME_CUSTOM_COLOURS_FG, "medium" );
81
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_FG_DARK = key( KEY_UI_THEME_CUSTOM_COLOURS_FG, "dark" );
82
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_ACCENT = key( KEY_UI_THEME_CUSTOM_COLOURS, "accent" );
83
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_UNFOCUSED = key( KEY_UI_THEME_CUSTOM_COLOURS, "unfocused" );
84
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR = key( KEY_UI_THEME_CUSTOM_COLOURS, "scrollbar" );
85
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR_BUTTON = key( KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR, "button" );
86
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR_BUTTON_RELEASED = key( KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR_BUTTON, "released" );
87
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR_BUTTON_PRESSED = key( KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR_BUTTON, "pressed" );
88
//  public static final Key KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR_BUTTON_HOVER = key( KEY_UI_THEME_CUSTOM_COLOURS_SCROLLBAR_BUTTON, "hover" );
89
90
  public static final Key KEY_LANGUAGE = key( KEY_ROOT, "language" );
91
  public static final Key KEY_LANGUAGE_LOCALE = key( KEY_LANGUAGE, "locale" );
92
  //@formatter:on
93
94
  /**
95
   *
96
   */
97
  private WorkspaceKeys() { }
98
}
199
M src/main/java/com/keenwrite/preview/HtmlPanel.java
2323
import static java.awt.Desktop.getDesktop;
2424
import static javax.swing.SwingUtilities.invokeLater;
25
import static javax.swing.SwingUtilities.isEventDispatchThread;
2526
import static org.jsoup.Jsoup.parse;
2627
...
105106
  public void render( final String html, final String baseUri ) {
106107
    final var doc = CONVERTER.fromJsoup( parse( html ) );
108
    final Runnable renderDocument = () -> setDocument( doc, baseUri, XNH );
107109
108110
    // Access to a Swing component must occur from the Event Dispatch
109111
    // Thread (EDT) according to Swing threading restrictions. Setting a new
110112
    // document invokes a Swing repaint operation.
111
    invokeLater( () -> setDocument( doc, baseUri, XNH ) );
113
    if( isEventDispatchThread() ) {
114
      renderDocument.run();
115
    }
116
    else {
117
      invokeLater( renderDocument );
118
    }
112119
  }
113120
M src/main/java/com/keenwrite/preview/HtmlPreview.java
44
import com.keenwrite.preferences.LocaleProperty;
55
import com.keenwrite.preferences.Workspace;
6
import javafx.application.Platform;
67
import javafx.beans.property.DoubleProperty;
78
import javafx.beans.property.StringProperty;
...
1819
import static com.keenwrite.Constants.*;
1920
import static com.keenwrite.Messages.get;
20
import static com.keenwrite.preferences.Workspace.*;
21
import static com.keenwrite.StatusNotifier.clue;
22
import static com.keenwrite.preferences.WorkspaceKeys.*;
2123
import static java.lang.Math.max;
2224
import static java.lang.String.format;
25
import static java.lang.Thread.sleep;
26
import static javafx.application.Platform.runLater;
2327
import static javafx.scene.CacheHint.SPEED;
2428
import static javax.swing.SwingUtilities.invokeLater;
...
175179
  /**
176180
   * Scrolls to the closest element matching the given identifier without
177
   * waiting for the document to be ready. Be sure the document is ready
178
   * before calling this method.
181
   * waiting for the document to be ready.
179182
   *
180183
   * @param id Scroll the preview pane to this unique paragraph identifier.
181184
   */
182185
  public void scrollTo( final String id ) {
183
    scrollTo( mView.getBoxById( id ) );
186
    final Runnable scrollToBox = () -> {
187
      int iter = 0;
188
      Box box = null;
189
190
      while( iter++ < 3 && ((box = mView.getBoxById( id )) == null) ) {
191
        try {
192
          sleep( 10 );
193
        } catch( final InterruptedException ex ) {
194
          clue( ex );
195
        }
196
      }
197
198
      scrollTo( box );
199
    };
200
201
    if( Platform.isFxApplicationThread() ) {
202
      scrollToBox.run();
203
    }
204
    else {
205
      runLater( scrollToBox );
206
    }
184207
  }
185208
...
279302
280303
  private LocaleProperty localeProperty() {
281
    return mWorkspace.localeProperty( KEY_LANG_LOCALE );
304
    return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE );
282305
  }
283306
A src/main/java/com/keenwrite/preview/SmoothScrollPane.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import javax.swing.*;
5
import java.awt.*;
6
import java.util.function.Consumer;
7
8
import static java.awt.Scrollbar.VERTICAL;
9
import static java.lang.Math.min;
10
import static javafx.animation.Interpolator.EASE_BOTH;
11
12
/**
13
 * Responsible for smoothing out the scrolling using an easing algorithm.
14
 *
15
 * @deprecated Does not refresh properly, has tearing of large images, and
16
 * jerks around when dragging the thumb (track).
17
 */
18
@Deprecated
19
public class SmoothScrollPane extends JScrollPane {
20
21
  public SmoothScrollPane( final Component component ) {
22
    super( component );
23
    setVerticalScrollBarPolicy( VERTICAL_SCROLLBAR_ALWAYS );
24
  }
25
26
  @Override
27
  public ScrollBar createVerticalScrollBar() {
28
    return new SmoothScrollBar( VERTICAL );
29
  }
30
31
  private class SmoothScrollBar extends ScrollBar implements Consumer<Integer> {
32
    private final Animator mAnimator = new Animator( this, () -> {
33
      // Fails to fix refresh problems when scrolling finishes. This is the
34
      // reason the class is deprecated. Calling invokeLater helps a little.
35
      SmoothScrollPane.this.getViewport().revalidate();
36
      revalidate();
37
      repaint();
38
    } );
39
40
    public SmoothScrollBar( final int orientation ) {
41
      super( orientation );
42
    }
43
44
    @Override
45
    public void setValue( final int nPos ) {
46
      final var oPos = getModel().getValue();
47
48
      mAnimator.stop();
49
      mAnimator.restart( oPos, nPos, 250 );
50
      new Thread( mAnimator ).start();
51
    }
52
53
    @Override
54
    public void accept( final Integer nPos ) {
55
      super.setValue( nPos );
56
    }
57
  }
58
59
  private static class Animator implements Runnable {
60
    private final Consumer<Integer> mAction;
61
    private final Runnable mComplete;
62
63
    private int mOldPos;
64
    private int mNewPos;
65
    private long mBegan;
66
    private long mEnded;
67
    private volatile boolean mRunning;
68
69
    public Animator( final Consumer<Integer> action, final Runnable complete ) {
70
      mAction = action;
71
      mComplete = complete;
72
    }
73
74
    /**
75
     * @param oPos Old scroll bar position.
76
     * @param nPos New scroll bar position.
77
     * @param time Total time to complete the scroll event (in milliseconds).
78
     */
79
    public void restart( final int oPos, final int nPos, final int time ) {
80
      mOldPos = oPos;
81
      mNewPos = nPos;
82
      mBegan = System.nanoTime();
83
      mEnded = time * 1_000_000L;
84
      mRunning = true;
85
    }
86
87
    public void stop() {
88
      mRunning = false;
89
    }
90
91
    @Override
92
    public void run() {
93
      double ratio;
94
95
      do {
96
        ratio = min( (double) (System.nanoTime() - mBegan) / mEnded, 1.0 );
97
        final int nPos = EASE_BOTH.interpolate( mOldPos, mNewPos, ratio );
98
99
        mAction.accept( nPos );
100
      } while( ratio <= 1 && mRunning );
101
102
      mComplete.run();
103
    }
104
  }
105
}
1106
M src/main/java/com/keenwrite/processors/XmlProcessor.java
22
package com.keenwrite.processors;
33
4
import com.keenwrite.Services;
5
import com.keenwrite.service.Snitch;
64
import net.sf.saxon.TransformerFactoryImpl;
75
import net.sf.saxon.trans.XPathException;
...
2018
import java.nio.file.Paths;
2119
20
import static javax.xml.stream.XMLInputFactory.newInstance;
2221
import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute;
2322
...
3433
 */
3534
public final class XmlProcessor extends ExecutorProcessor<String>
36
    implements ErrorListener {
37
38
  private final Snitch mSnitch = Services.load( Snitch.class );
35
  implements ErrorListener {
3936
40
  private final XMLInputFactory mXmlInputFactory =
41
      XMLInputFactory.newInstance();
37
  private final XMLInputFactory mXmlInputFactory = newInstance();
4238
  private final TransformerFactory mTransformerFactory =
43
      new TransformerFactoryImpl();
39
    new TransformerFactoryImpl();
4440
  private Transformer mTransformer;
4541
...
5652
   */
5753
  public XmlProcessor(
58
      final Processor<String> successor,
59
      final ProcessorContext context ) {
54
    final Processor<String> successor,
55
    final ProcessorContext context ) {
6056
    super( successor );
6157
    mPath = context.getDocumentPath();
...
9490
9591
    try(
96
        final StringWriter output = new StringWriter( text.length() );
97
        final StringReader input = new StringReader( text ) ) {
92
      final StringWriter output = new StringWriter( text.length() );
93
      final StringReader input = new StringReader( text ) ) {
9894
95
      // TODO: Use FileWatchService
9996
      // Listen for external file modification events.
100
      mSnitch.listen( xsl );
97
      // mSnitch.listen( xsl );
10198
10299
      getTransformer( xsl ).transform(
103
          new StreamSource( input ),
104
          new StreamResult( output )
100
        new StreamSource( input ),
101
        new StreamResult( output )
105102
      );
106103
...
115112
   *
116113
   * @param xsl The path to an XSLT file.
117
   * @return A transformer that will transform XML documents using the given
118
   * XSLT file.
119
   * @throws TransformerConfigurationException Could not instantiate the
120
   *                                           transformer.
114
   * @return Transformer that transforms XML documents using said XSLT file.
115
   * @throws TransformerConfigurationException Instantiate transformer failed.
121116
   */
122117
  private synchronized Transformer getTransformer( final Path xsl )
123
      throws TransformerConfigurationException {
118
    throws TransformerConfigurationException {
124119
    if( mTransformer == null ) {
125120
      mTransformer = createTransformer( xsl );
...
133128
   *
134129
   * @param xsl The stylesheet to use for transforming XML documents.
135
   * @return The edited XML document transformed into another format (usually
136
   * Markdown).
130
   * @return XML document transformed into another format (usually Markdown).
137131
   * @throws TransformerConfigurationException Could not create the transformer.
138132
   */
139133
  protected Transformer createTransformer( final Path xsl )
140
      throws TransformerConfigurationException {
134
    throws TransformerConfigurationException {
141135
    final var xslt = new StreamSource( xsl.toFile() );
142136
...
160154
   */
161155
  private String getXsltFilename( final String xml )
162
      throws XMLStreamException, XPathException {
156
    throws XMLStreamException, XPathException {
163157
    String result = "";
164158
...
189183
190184
  private XMLEventReader createXmlEventReader( final Reader reader )
191
      throws XMLStreamException {
185
    throws XMLStreamException {
192186
    return mXmlInputFactory.createXMLEventReader( reader );
193187
  }
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
1919
import static com.keenwrite.ExportFormat.NONE;
2020
import static com.keenwrite.StatusNotifier.clue;
21
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_DIR;
22
import static com.keenwrite.preferences.Workspace.KEY_IMAGES_ORDER;
21
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_DIR;
22
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_ORDER;
2323
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2424
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
M src/main/java/com/keenwrite/processors/r/InlineRProcessor.java
2020
import static com.keenwrite.Messages.get;
2121
import static com.keenwrite.StatusNotifier.clue;
22
import static com.keenwrite.preferences.Workspace.*;
22
import static com.keenwrite.preferences.WorkspaceKeys.*;
2323
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
2424
import static com.keenwrite.sigils.RSigilOperator.PREFIX;
M src/main/java/com/keenwrite/processors/r/RVariableProcessor.java
1212
import java.util.Map;
1313
14
import static com.keenwrite.preferences.Workspace.*;
14
import static com.keenwrite.preferences.WorkspaceKeys.*;
1515
1616
/**
...
6868
   * @return The haystack with the all instances of needle replaced with thread.
6969
   */
70
  @SuppressWarnings("SameParameterValue")
70
  @SuppressWarnings( "SameParameterValue" )
7171
  private String escape(
7272
    final String haystack, final char needle, final String thread ) {
...
9999
  }
100100
101
  private SigilOperator createDefinitionOperator(
102
    final Workspace workspace ) {
101
  private SigilOperator createDefinitionOperator( final Workspace workspace ) {
103102
    final var tokens = workspace.toTokens(
104103
      KEY_DEF_DELIM_BEGAN, KEY_DEF_DELIM_ENDED );
D src/main/java/com/keenwrite/service/Snitch.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.service;
3
4
import java.io.IOException;
5
import java.nio.file.Path;
6
import java.util.Observer;
7
8
/**
9
 * Listens for changes to file system files and directories.
10
 */
11
public interface Snitch extends Service, Runnable {
12
13
  /**
14
   * Adds an observer to the set of observers for this object, provided that it
15
   * is not the same as some observer already in the set. The order in which
16
   * notifications will be delivered to multiple observers is not specified.
17
   *
18
   * @param o The object to receive changed events for when monitored files
19
   *          are changed.
20
   */
21
  void addObserver( Observer o );
22
23
  /**
24
   * Listens for changes to the path. If the path specifies a file, then only
25
   * notifications pertaining to that file are sent. Otherwise, change events
26
   * for the directory that contains the file are sent. This method must allow
27
   * for multiple calls to the same file without incurring additional listeners
28
   * or events.
29
   *
30
   * @param file Send notifications when this file changes, can be null.
31
   * @throws IOException Couldn't create a watcher for the given file.
32
   */
33
  void listen( Path file ) throws IOException;
34
35
  /**
36
   * Removes the given file from the notifications list.
37
   *
38
   * @param file The file to stop monitoring for any changes, can be null.
39
   */
40
  void ignore( Path file );
41
42
  /**
43
   * Start listening for events on a new thread.
44
   */
45
  void start();
46
47
  /**
48
   * Stop listening for events.
49
   */
50
  void stop();
51
}
521
D src/main/java/com/keenwrite/service/impl/DefaultSnitch.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.service.impl;
3
4
import com.keenwrite.service.Snitch;
5
6
import java.io.IOException;
7
import java.nio.file.*;
8
import java.util.Collections;
9
import java.util.Map;
10
import java.util.Observable;
11
import java.util.Set;
12
import java.util.concurrent.ConcurrentHashMap;
13
14
import static com.keenwrite.Constants.APP_WATCHDOG_TIMEOUT;
15
import static com.keenwrite.StatusNotifier.clue;
16
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
17
18
/**
19
 * Listens for file changes. Other classes can register paths to be monitored
20
 * and listen for changes to those paths.
21
 */
22
public class DefaultSnitch extends Observable implements Snitch {
23
  private final Thread mSnitchThread = new Thread( this );
24
25
  /**
26
   * Service for listening to directories for modifications.
27
   */
28
  private WatchService watchService;
29
30
  /**
31
   * Directories being monitored for changes.
32
   */
33
  private Map<WatchKey, Path> keys;
34
35
  /**
36
   * Files that will kick off notification events if modified.
37
   */
38
  private Set<Path> eavesdropped;
39
40
  /**
41
   * Set to true when running; set to false to stop listening.
42
   */
43
  private volatile boolean listening;
44
45
  public DefaultSnitch() {
46
  }
47
48
  @Override
49
  public void start() {
50
    mSnitchThread.start();
51
  }
52
53
  @Override
54
  public void stop() {
55
    setListening( false );
56
57
    try {
58
      mSnitchThread.interrupt();
59
      mSnitchThread.join();
60
    } catch( final Exception ex ) {
61
      clue( ex );
62
    }
63
  }
64
65
  /**
66
   * Adds a listener to the list of files to watch for changes. If the file is
67
   * already in the monitored list, this will return immediately.
68
   *
69
   * @param file Path to a file to watch for changes.
70
   * @throws IOException The file could not be monitored.
71
   */
72
  @Override
73
  public void listen( final Path file ) throws IOException {
74
    if( file != null && getEavesdropped().add( file ) ) {
75
      final Path dir = toDirectory( file );
76
      final WatchKey key = dir.register( getWatchService(), ENTRY_MODIFY );
77
78
      getWatchMap().put( key, dir );
79
    }
80
  }
81
82
  /**
83
   * Returns the given path to a file (or directory) as a directory. If the
84
   * given path is already a directory, it is returned. Otherwise, this returns
85
   * the directory that contains the file. This will fail if the file is stored
86
   * in the root folder.
87
   *
88
   * @param path The file to return as a directory, which should always be the
89
   *             case.
90
   * @return The given path as a directory, if a file, otherwise the path
91
   * itself.
92
   */
93
  private Path toDirectory( final Path path ) {
94
    return Files.isDirectory( path )
95
        ? path
96
        : path.toFile().getParentFile().toPath();
97
  }
98
99
  /**
100
   * Stop listening to the given file for change events. This fails silently.
101
   *
102
   * @param file The file to no longer monitor for changes.
103
   */
104
  @Override
105
  public void ignore( final Path file ) {
106
    if( file != null ) {
107
      final Path directory = toDirectory( file );
108
109
      // Remove all occurrences (there should be only one).
110
      getWatchMap().values().removeAll( Collections.singleton( directory ) );
111
112
      // Remove all occurrences (there can be only one).
113
      getEavesdropped().remove( file );
114
    }
115
  }
116
117
  /**
118
   * Loops until stop is called, or the application is terminated.
119
   */
120
  @Override
121
  @SuppressWarnings("BusyWait")
122
  public void run() {
123
    setListening( true );
124
125
    while( isListening() ) {
126
      try {
127
        final WatchKey key = getWatchService().take();
128
        final Path path = get( key );
129
130
        // Prevent receiving two separate ENTRY_MODIFY events: file modified
131
        // and timestamp updated. Instead, receive one ENTRY_MODIFY event
132
        // with two counts.
133
        Thread.sleep( APP_WATCHDOG_TIMEOUT );
134
135
        for( final WatchEvent<?> event : key.pollEvents() ) {
136
          final Path changed = path.resolve( (Path) event.context() );
137
138
          if( event.kind() == ENTRY_MODIFY && isListening( changed ) ) {
139
            setChanged();
140
            notifyObservers( changed );
141
          }
142
        }
143
144
        if( !key.reset() ) {
145
          ignore( path );
146
        }
147
      } catch( final Exception ex ) {
148
        // Stop eavesdropping.
149
        setListening( false );
150
      }
151
    }
152
  }
153
154
  /**
155
   * Returns true if the list of files being listened to for changes contains
156
   * the given file.
157
   *
158
   * @param file Path to a system file.
159
   * @return true The given file is being monitored for changes.
160
   */
161
  private boolean isListening( final Path file ) {
162
    return getEavesdropped().contains( file );
163
  }
164
165
  /**
166
   * Returns a path for a given watch key.
167
   *
168
   * @param key The key to lookup its corresponding path.
169
   * @return The path for the given key.
170
   */
171
  private Path get( final WatchKey key ) {
172
    return getWatchMap().get( key );
173
  }
174
175
  private synchronized Map<WatchKey, Path> getWatchMap() {
176
    if( this.keys == null ) {
177
      this.keys = createWatchKeys();
178
    }
179
180
    return this.keys;
181
  }
182
183
  protected Map<WatchKey, Path> createWatchKeys() {
184
    return new ConcurrentHashMap<>();
185
  }
186
187
  /**
188
   * Returns a list of files that, when changed, will kick off a notification.
189
   *
190
   * @return A non-null, possibly empty, list of files.
191
   */
192
  private synchronized Set<Path> getEavesdropped() {
193
    if( this.eavesdropped == null ) {
194
      this.eavesdropped = createEavesdropped();
195
    }
196
197
    return this.eavesdropped;
198
  }
199
200
  protected Set<Path> createEavesdropped() {
201
    return ConcurrentHashMap.newKeySet();
202
  }
203
204
  /**
205
   * The existing watch service, or a new instance if null.
206
   *
207
   * @return A valid WatchService instance, never null.
208
   * @throws IOException Could not create a new watch service.
209
   */
210
  private synchronized WatchService getWatchService() throws IOException {
211
    if( this.watchService == null ) {
212
      this.watchService = createWatchService();
213
    }
214
215
    return this.watchService;
216
  }
217
218
  protected WatchService createWatchService() throws IOException {
219
    final FileSystem fileSystem = FileSystems.getDefault();
220
    return fileSystem.newWatchService();
221
  }
222
223
  /**
224
   * Answers whether the loop should continue executing.
225
   *
226
   * @return true The internal listening loop should continue listening for file
227
   * modification events.
228
   */
229
  protected boolean isListening() {
230
    return this.listening;
231
  }
232
233
  /**
234
   * Requests the snitch to stop eavesdropping on file changes.
235
   *
236
   * @param listening Use true to indicate the service should stop running.
237
   */
238
  private void setListening( final boolean listening ) {
239
    this.listening = listening;
240
  }
241
}
2421
M src/main/java/com/keenwrite/sigils/RSigilOperator.java
1919
  private final SigilOperator mAntecedent;
2020
21
  /**
22
   * Constructs a new {@link RSigilOperator} capable of wrapping tokens around
23
   * variable names (keys).
24
   *
25
   * @param tokens     The starting and ending tokens.
26
   * @param antecedent The operator to use to undo any previous entokenizing.
27
   */
2128
  public RSigilOperator( final Tokens tokens, final SigilOperator antecedent ) {
2229
    super( tokens );
2330
2431
    mAntecedent = antecedent;
2532
  }
2633
2734
  /**
28
   * Returns the given string R-escaping backticks prepended and appended. This
29
   * is not null safe. Do not pass null into this method.
35
   * Returns the given string with backticks prepended and appended. The
3036
   *
3137
   * @param key The string to adorn with R token delimiters.
32
   * @return PREFIX + delimiterBegan + variableName+ delimiterEnded + SUFFIX.
38
   * @return PREFIX + delimiterBegan + variableName + delimiterEnded + SUFFIX.
3339
   */
3440
  @Override
3541
  public String apply( final String key ) {
3642
    assert key != null;
37
    return PREFIX + getBegan() + entoken( key ) + getEnded() + SUFFIX;
43
    return PREFIX + getBegan() + key + getEnded() + SUFFIX;
3844
  }
3945
M src/main/java/com/keenwrite/sigils/Tokens.java
77
88
/**
9
 * Convenience class for pairing a start and end sigil together.
9
 * Convenience class for pairing a start and an end sigil together.
1010
 */
1111
public final class Tokens
M src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
2323
import javafx.stage.WindowEvent;
2424
25
import static com.keenwrite.Bootstrap.APP_TITLE;
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.Workspace.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
        final var m = get( "Main.status.export.success", f.toString() );
140
        clue( m );
141
      } catch( final Exception ex ) {
142
        clue( ex );
143
      }
144
    } );
145
  }
146
147
  public void file‿exit() {
148
    final var window = getWindow();
149
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
150
  }
151
152
  public void edit‿undo() {
153
    getActiveTextEditor().undo();
154
  }
155
156
  public void edit‿redo() {
157
    getActiveTextEditor().redo();
158
  }
159
160
  public void edit‿cut() {
161
    getActiveTextEditor().cut();
162
  }
163
164
  public void edit‿copy() {
165
    getActiveTextEditor().copy();
166
  }
167
168
  public void edit‿paste() {
169
    getActiveTextEditor().paste();
170
  }
171
172
  public void edit‿select_all() {
173
    getActiveTextEditor().selectAll();
174
  }
175
176
  public void edit‿find() {
177
    final var nodes = getStatusBar().getLeftItems();
178
179
    if( nodes.isEmpty() ) {
180
      final var searchBar = new SearchBar();
181
182
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
183
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
184
185
      searchBar.setOnCancelAction( ( event ) -> {
186
        final var editor = getActiveTextEditor();
187
        nodes.remove( searchBar );
188
        editor.unstylize( STYLE_SEARCH );
189
        editor.getNode().requestFocus();
190
      } );
191
192
      searchBar.addInputListener( ( c, o, n ) -> {
193
        if( n != null && !n.isEmpty() ) {
194
          mSearchModel.search( n, getActiveTextEditor().getText() );
195
        }
196
      } );
197
198
      searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
199
      searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
200
201
      nodes.add( searchBar );
202
      searchBar.requestFocus();
203
    }
204
    else {
205
      nodes.clear();
206
    }
207
  }
208
209
  public void edit‿find_next() {
210
    mSearchModel.advance();
211
  }
212
213
  public void edit‿find_prev() {
214
    mSearchModel.retreat();
215
  }
216
217
  public void edit‿preferences() {
218
    new PreferencesController( getWorkspace() ).show();
219
  }
220
221
  public void format‿bold() {
222
    getActiveTextEditor().bold();
223
  }
224
225
  public void format‿italic() {
226
    getActiveTextEditor().italic();
227
  }
228
229
  public void format‿superscript() {
230
    getActiveTextEditor().superscript();
231
  }
232
233
  public void format‿subscript() {
234
    getActiveTextEditor().subscript();
235
  }
236
237
  public void format‿strikethrough() {
238
    getActiveTextEditor().strikethrough();
239
  }
240
241
  public void insert‿blockquote() {
242
    getActiveTextEditor().blockquote();
243
  }
244
245
  public void insert‿code() {
246
    getActiveTextEditor().code();
247
  }
248
249
  public void insert‿fenced_code_block() {
250
    getActiveTextEditor().fencedCodeBlock();
251
  }
252
253
  public void insert‿link() {
254
    insertObject( createLinkDialog() );
255
  }
256
257
  public void insert‿image() {
258
    insertObject( createImageDialog() );
259
  }
260
261
  private void insertObject( final Dialog<String> dialog ) {
262
    final var textArea = getActiveTextEditor().getTextArea();
263
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
264
  }
265
266
  private Dialog<String> createLinkDialog() {
267
    return new LinkDialog( getWindow(), createHyperlinkModel() );
268
  }
269
270
  private Dialog<String> createImageDialog() {
271
    final var path = getActiveTextEditor().getPath();
272
    final var parentDir = path.getParent();
273
    return new ImageDialog( getWindow(), parentDir );
274
  }
275
276
  /**
277
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
278
   * the Markdown AST.
279
   *
280
   * @return An instance containing the link URL and display text.
281
   */
282
  private HyperlinkModel createHyperlinkModel() {
283
    final var context = getMainPane().createProcessorContext();
284
    final var editor = getActiveTextEditor();
285
    final var textArea = editor.getTextArea();
286
    final var selectedText = textArea.getSelectedText();
287
288
    // Convert current paragraph to Markdown nodes.
289
    final var mp = MarkdownProcessor.create( context );
290
    final var p = textArea.getCurrentParagraph();
291
    final var paragraph = textArea.getText( p );
292
    final var node = mp.toNode( paragraph );
293
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
294
    final var link = visitor.process( node );
295
296
    if( link != null ) {
297
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
298
    }
299
300
    return createHyperlinkModel( link, selectedText );
301
  }
302
303
  private HyperlinkModel createHyperlinkModel(
304
    final Link link, final String selection ) {
305
306
    return link == null
307
      ? new HyperlinkModel( selection, "https://localhost" )
308
      : new HyperlinkModel( link );
309
  }
310
311
  public void insert‿heading_1() {
312
    insert‿heading( 1 );
313
  }
314
315
  public void insert‿heading_2() {
316
    insert‿heading( 2 );
317
  }
318
319
  public void insert‿heading_3() {
320
    insert‿heading( 3 );
321
  }
322
323
  private void insert‿heading( final int level ) {
324
    getActiveTextEditor().heading( level );
325
  }
326
327
  public void insert‿unordered_list() {
328
    getActiveTextEditor().unorderedList();
329
  }
330
331
  public void insert‿ordered_list() {
332
    getActiveTextEditor().orderedList();
333
  }
334
335
  public void insert‿horizontal_rule() {
336
    getActiveTextEditor().horizontalRule();
337
  }
338
339
  public void definition‿create() {
340
    getActiveTextDefinition().createDefinition();
341
  }
342
343
  public void definition‿rename() {
344
    getActiveTextDefinition().renameDefinition();
345
  }
346
347
  public void definition‿delete() {
348
    getActiveTextDefinition().deleteDefinitions();
349
  }
350
351
  public void definition‿autoinsert() {
352
    getMainPane().autoinsert();
353
  }
354
355
  public void view‿refresh() {
356
    getMainPane().viewRefresh();
357
  }
358
359
  public void view‿preview() {
360
    getMainPane().viewPreview();
361
  }
362
363
  public void view‿menubar() {
364
    getMainScene().toggleMenuBar();
365
  }
366
367
  public void view‿toolbar() {
368
    getMainScene().toggleToolBar();
369
  }
370
371
  public void view‿statusbar() {
372
    getMainScene().toggleStatusBar();
373
  }
374
375
  public void view‿issues() {
376
    StatusNotifier.viewIssues();
377
  }
378
379
  public void help‿about() {
380
    final Alert alert = new Alert( INFORMATION );
381
    alert.setTitle( get( "Dialog.about.title", APP_TITLE ) );
382
    alert.setHeaderText( get( "Dialog.about.header", APP_TITLE ) );
383
    alert.setContentText( get( "Dialog.about.content" ) );
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();
376
  }
377
378
  public void help‿about() {
379
    final var alert = new Alert( INFORMATION );
380
    final var prefix = "Dialog.about.";
381
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
382
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
383
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
384384
    alert.setGraphic( ICON_DIALOG_NODE );
385385
    alert.initOwner( getWindow() );
M src/main/java/com/keenwrite/ui/controls/SearchBar.java
1212
import javafx.geometry.Pos;
1313
import javafx.scene.Node;
14
import javafx.scene.control.Button;
15
import javafx.scene.control.Separator;
16
import javafx.scene.control.TextField;
17
import javafx.scene.control.Tooltip;
14
import javafx.scene.control.*;
1815
import javafx.scene.layout.HBox;
1916
import javafx.scene.layout.Priority;
2017
import javafx.scene.layout.Region;
2118
import javafx.scene.layout.VBox;
22
import javafx.scene.text.Text;
2319
import org.controlsfx.control.textfield.CustomTextField;
2420
...
3935
  private final Button mButtonPrev = createButton( "prev" );
4036
  private final TextField mFind = createTextField();
41
  private final Text mMatches = new Text();
37
  private final Label mMatches = new Label();
4238
  private final IntegerProperty mMatchIndex = new SimpleIntegerProperty();
4339
  private final IntegerProperty mMatchCount = new SimpleIntegerProperty();
M src/main/java/com/keenwrite/ui/listeners/CaretListener.java
11
package com.keenwrite.ui.listeners;
22
3
import com.keenwrite.editors.TextEditor;
43
import com.keenwrite.Caret;
4
import com.keenwrite.editors.TextEditor;
55
import javafx.beans.property.ReadOnlyObjectProperty;
66
import javafx.beans.value.ChangeListener;
77
import javafx.beans.value.ObservableValue;
8
import javafx.scene.control.Label;
89
import javafx.scene.layout.VBox;
9
import javafx.scene.text.Text;
1010
1111
import static javafx.geometry.Pos.BASELINE_CENTER;
1212
1313
/**
1414
 * Responsible for updating the UI whenever the caret changes position.
15
 * Only one instance of {@link CaretListener} is allowed to prevent duplicate
16
 * adds to the observable property.
15
 * Only one instance of {@link CaretListener} is allowed, which prevents
16
 * duplicate adds to the observable property.
1717
 */
1818
public class CaretListener extends VBox implements ChangeListener<Integer> {
1919
20
  private final Text mLineNumberText = new Text();
20
  /**
21
   * Use an instance of {@link Label} for its built-in CSS style class.
22
   */
23
  private final Label mLineNumberText = new Label();
2124
  private volatile Caret mCaret;
2225
D src/main/resources/META-INF/services/com.keenwrite.service.Snitch
1
com.keenwrite.service.impl.DefaultSnitch
1
M src/main/resources/com/keenwrite/editor/markdown.css
77
}
88
9
/* Editor background color */
10
.styled-text-area {
11
  -fx-background-color: -fx-control-inner-background;
12
}
13
14
/* Text foreground colour */
15
.styled-text-area .text {
16
  -fx-fill: -fx-text-foreground;
17
}
18
919
/* Subtly highlight the current paragraph. */
1020
.markdown .paragraph-box:has-caret {
11
  -fx-background-color: #fcfeff;
21
  -fx-background-color: -fx-text-background;
1222
}
1323
1424
/* Light colour for selection highlight. */
1525
.markdown .selection {
16
  -fx-fill: #a6d2ff;
26
  -fx-fill: -fx-text-selection;
1727
}
1828
1929
/* Decoration for words not found in the lexicon. */
2030
.markdown .spelling {
21
  -rtfx-underline-color: rgba(255, 131, 67, .7);
31
  -rtfx-underline-color: rgba( 255, 131, 67, .7 );
2232
  -rtfx-underline-dash-array: 4, 2;
2333
  -rtfx-underline-width: 2;
M src/main/resources/com/keenwrite/messages.properties
105105
workspace.definition.delimiter.ended.title=Closing
106106
107
workspace.ui.font=Fonts
108
workspace.ui.font.editor=Editor Font
109
workspace.ui.font.editor.name=Name
110
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
111
workspace.ui.font.editor.name.title=Family
112
workspace.ui.font.editor.size=Size
113
workspace.ui.font.editor.size.desc=Font size.
114
workspace.ui.font.editor.size.title=Points
115
workspace.ui.font.preview=Preview Font
116
workspace.ui.font.preview.name=Name
117
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
118
workspace.ui.font.preview.name.title=Family
119
workspace.ui.font.preview.size=Size
120
workspace.ui.font.preview.size.desc=Font size.
121
workspace.ui.font.preview.size.title=Points
122
workspace.ui.font.preview.mono.name=Name
123
workspace.ui.font.preview.mono.name.desc=Monospace font name.
124
workspace.ui.font.preview.mono.name.title=Family
125
workspace.ui.font.preview.mono.size=Size
126
workspace.ui.font.preview.mono.size.desc=Monospace font size.
127
workspace.ui.font.preview.mono.size.title=Points
128
129
workspace.language=Language
130
workspace.language.locale=Internationalization
131
workspace.language.locale.desc=Language for application and HTML export.
132
workspace.language.locale.title=Locale
133
134
# ########################################################################
135
# Definition Pane and its Tree View
136
# ########################################################################
137
138
Definition.menu.add.default=Undefined
139
140
# ########################################################################
141
# Definition Pane
142
# ########################################################################
143
144
Pane.definition.node.root.title=Definitions
145
146
# ########################################################################
147
# Failure messages with respect to YAML files.
148
# ########################################################################
149
150
yaml.error.open=Could not open YAML file (ensure non-empty file).
151
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
152
yaml.error.missing=Empty definition value for key ''{0}''.
153
yaml.error.tree.form=Unassigned definition near ''{0}''.
154
155
# ########################################################################
156
# Text Resource
157
# ########################################################################
158
159
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
160
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
161
162
# ########################################################################
163
# Text Resources
164
# ########################################################################
165
166
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
167
TextResource.saveFailed.title=Save
168
169
# ########################################################################
170
# File Open
171
# ########################################################################
172
173
Dialog.file.choose.open.title=Open File
174
Dialog.file.choose.save.title=Save File
175
Dialog.file.choose.export.title=Export File
176
177
Dialog.file.choose.filter.title.source=Source Files
178
Dialog.file.choose.filter.title.definition=Definition Files
179
Dialog.file.choose.filter.title.xml=XML Files
180
Dialog.file.choose.filter.title.all=All Files
181
182
# ########################################################################
183
# Browse File
184
# ########################################################################
185
186
BrowseFileButton.chooser.title=Browse for local file
187
BrowseFileButton.chooser.allFilesFilter=All Files
188
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
189
190
# ########################################################################
191
# Alert Dialog
192
# ########################################################################
193
194
Alert.file.close.title=Close
195
Alert.file.close.text=Save changes to {0}?
196
197
# ########################################################################
198
# Image Dialog
199
# ########################################################################
200
201
Dialog.image.title=Image
202
Dialog.image.chooser.imagesFilter=Images
203
Dialog.image.previewLabel.text=Markdown Preview\:
204
Dialog.image.textLabel.text=Alternate Text\:
205
Dialog.image.titleLabel.text=Title (tooltip)\:
206
Dialog.image.urlLabel.text=Image URL\:
207
208
# ########################################################################
209
# Hyperlink Dialog
210
# ########################################################################
211
212
Dialog.link.title=Link
213
Dialog.link.previewLabel.text=Markdown Preview\:
214
Dialog.link.textLabel.text=Link Text\:
215
Dialog.link.titleLabel.text=Title (tooltip)\:
216
Dialog.link.urlLabel.text=Link URL\:
217
218
# ########################################################################
219
# About Dialog
220
# ########################################################################
221
222
Dialog.about.title=About {0}
223
Dialog.about.header={0}
224
Dialog.about.content=Copyright 2020 White Magic Software, Ltd.\n\nBased on Markdown Writer FX by Karl Tauber
225
226
# ########################################################################
227
# Application Actions
228
# ########################################################################
229
230
App.action.file.new.description=Create a new file
231
App.action.file.new.accelerator=Shortcut+N
232
App.action.file.new.icon=FILE_ALT
233
App.action.file.new.text=_New
234
235
App.action.file.open.description=Open a new file
236
App.action.file.open.accelerator=Shortcut+O
237
App.action.file.open.text=_Open...
238
App.action.file.open.icon=FOLDER_OPEN_ALT
239
240
App.action.file.close.description=Close the current document
241
App.action.file.close.accelerator=Shortcut+W
242
App.action.file.close.text=_Close
243
244
App.action.file.close_all.description=Close all open documents
245
App.action.file.close_all.accelerator=Ctrl+F4
246
App.action.file.close_all.text=Close All
247
248
App.action.file.save.description=Save the document
249
App.action.file.save.accelerator=Shortcut+S
250
App.action.file.save.text=_Save
251
App.action.file.save.icon=FLOPPY_ALT
252
253
App.action.file.save_as.description=Rename the current document
254
App.action.file.save_as.text=Save _As
255
256
App.action.file.save_all.description=Save all open documents
257
App.action.file.save_all.accelerator=Shortcut+Shift+S
258
App.action.file.save_all.text=Save A_ll
259
260
App.action.file.export.html_svg.description=Export the current document as HTML + SVG
261
App.action.file.export.text=_Export As
262
App.action.file.export.html_svg.text=HTML and S_VG
263
264
App.action.file.export.html_tex.description=Export the current document as HTML + TeX
265
App.action.file.export.html_tex.text=HTML and _TeX
266
267
App.action.file.export.markdown.description=Export the current document as Markdown
268
App.action.file.export.markdown.text=Markdown
269
270
App.action.file.exit.description=Quit the application
271
App.action.file.exit.text=E_xit
272
273
274
App.action.edit.undo.description=Undo the previous edit
275
App.action.edit.undo.accelerator=Shortcut+Z
276
App.action.edit.undo.text=_Undo
277
App.action.edit.undo.icon=UNDO
278
279
App.action.edit.redo.description=Redo the previous edit
280
App.action.edit.redo.accelerator=Shortcut+Y
281
App.action.edit.redo.text=_Redo
282
App.action.edit.redo.icon=REPEAT
283
284
App.action.edit.cut.description=Delete the selected text or line
285
App.action.edit.cut.accelerator=Shortcut+X
286
App.action.edit.cut.text=Cu_t
287
App.action.edit.cut.icon=CUT
288
289
App.action.edit.copy.description=Copy the selected text
290
App.action.edit.copy.accelerator=Shortcut+C
291
App.action.edit.copy.text=_Copy
292
App.action.edit.copy.icon=COPY
293
294
App.action.edit.paste.description=Paste from the clipboard
295
App.action.edit.paste.accelerator=Shortcut+V
296
App.action.edit.paste.text=_Paste
297
App.action.edit.paste.icon=PASTE
298
299
App.action.edit.select_all.description=Highlight the current document text
300
App.action.edit.select_all.accelerator=Shortcut+A
301
App.action.edit.select_all.text=Select _All
302
303
App.action.edit.find.description=Search for text in the document
304
App.action.edit.find.accelerator=Shortcut+F
305
App.action.edit.find.text=_Find
306
App.action.edit.find.icon=SEARCH
307
308
App.action.edit.find_next.description=Find next occurrence
309
App.action.edit.find_next.accelerator=F3
310
App.action.edit.find_next.text=Find _Next
311
312
App.action.edit.find_prev.description=Find previous occurrence
313
App.action.edit.find_prev.accelerator=Shift+F3
314
App.action.edit.find_prev.text=Find _Prev
315
316
App.action.edit.preferences.description=Edit user preferences
317
App.action.edit.preferences.accelerator=Ctrl+Alt+S
318
App.action.edit.preferences.text=_Preferences
319
320
321
App.action.format.bold.description=Insert strong text
322
App.action.format.bold.accelerator=Shortcut+B
323
App.action.format.bold.text=_Bold
324
App.action.format.bold.icon=BOLD
325
326
App.action.format.italic.description=Insert text emphasis
327
App.action.format.italic.accelerator=Shortcut+I
328
App.action.format.italic.text=_Italic
329
App.action.format.italic.icon=ITALIC
330
331
App.action.format.superscript.description=Insert superscript text
332
App.action.format.superscript.accelerator=Shortcut+[
333
App.action.format.superscript.text=Su_perscript
334
App.action.format.superscript.icon=SUPERSCRIPT
335
336
App.action.format.subscript.description=Insert subscript text
337
App.action.format.subscript.accelerator=Shortcut+]
338
App.action.format.subscript.text=Su_bscript
339
App.action.format.subscript.icon=SUBSCRIPT
340
341
App.action.format.strikethrough.description=Insert struck text
342
App.action.format.strikethrough.accelerator=Shortcut+T
343
App.action.format.strikethrough.text=Stri_kethrough
344
App.action.format.strikethrough.icon=STRIKETHROUGH
345
346
347
App.action.insert.blockquote.description=Insert blockquote
348
App.action.insert.blockquote.accelerator=Ctrl+Q
349
App.action.insert.blockquote.text=_Blockquote
350
App.action.insert.blockquote.icon=QUOTE_LEFT
351
352
App.action.insert.code.description=Insert inline code
353
App.action.insert.code.accelerator=Shortcut+K
354
App.action.insert.code.text=Inline _Code
355
App.action.insert.code.icon=CODE
356
357
App.action.insert.fenced_code_block.description=Insert code block
358
App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
359
App.action.insert.fenced_code_block.text=_Fenced Code Block
360
App.action.insert.fenced_code_block.prompt.text=Enter code here
361
App.action.insert.fenced_code_block.icon=FILE_CODE_ALT
362
363
App.action.insert.link.description=Insert hyperlink
364
App.action.insert.link.accelerator=Shortcut+L
365
App.action.insert.link.text=_Link...
366
App.action.insert.link.icon=LINK
367
368
App.action.insert.image.description=Insert image
369
App.action.insert.image.accelerator=Shortcut+G
370
App.action.insert.image.text=_Image...
371
App.action.insert.image.icon=PICTURE_ALT
372
373
App.action.insert.heading.description=Insert heading level
374
App.action.insert.heading.accelerator=Shortcut+
375
App.action.insert.heading.icon=HEADER
376
377
App.action.insert.heading_1.description=${App.action.insert.heading.description} 1
378
App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1
379
App.action.insert.heading_1.text=Heading _1
380
App.action.insert.heading_1.icon=${App.action.insert.heading.icon}
381
382
App.action.insert.heading_2.description=${App.action.insert.heading.description} 2
383
App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2
384
App.action.insert.heading_2.text=Heading _2
385
App.action.insert.heading_2.icon=${App.action.insert.heading.icon}
386
387
App.action.insert.heading_3.description=${App.action.insert.heading.description} 3
388
App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3
389
App.action.insert.heading_3.text=Heading _3
390
App.action.insert.heading_3.icon=${App.action.insert.heading.icon}
391
392
App.action.insert.unordered_list.description=Insert bulleted list
393
App.action.insert.unordered_list.accelerator=Shortcut+U
394
App.action.insert.unordered_list.text=_Unordered List
395
App.action.insert.unordered_list.icon=LIST_UL
396
397
App.action.insert.ordered_list.description=Insert enumerated list
398
App.action.insert.ordered_list.accelerator=Shortcut+Shift+O
399
App.action.insert.ordered_list.text=_Ordered List
400
App.action.insert.ordered_list.icon=LIST_OL
401
402
App.action.insert.horizontal_rule.description=Insert horizontal rule
403
App.action.insert.horizontal_rule.accelerator=Shortcut+H
404
App.action.insert.horizontal_rule.text=_Horizontal Rule
405
App.action.insert.horizontal_rule.icon=LIST_OL
406
407
408
App.action.definition.create.description=Create a new variable definition
409
App.action.definition.create.text=_Create
410
App.action.definition.create.icon=TREE
411
App.action.definition.create.tooltip=Add new item (Insert)
412
413
App.action.definition.rename.description=Rename the selected variable definition
414
App.action.definition.rename.text=_Rename
415
App.action.definition.rename.icon=EDIT
416
App.action.definition.rename.tooltip=Rename selected item (F2)
417
418
App.action.definition.delete.description=Delete the selected variable definitions
419
App.action.definition.delete.text=_Delete
107
workspace.ui.theme=Themes
108
workspace.ui.theme.selection=Bundled
109
workspace.ui.theme.selection.desc=Pre-packaged application style (default: Modena Light)
110
workspace.ui.theme.selection.title=Name
111
workspace.ui.theme.custom=Custom
112
workspace.ui.theme.custom.desc=User-defined JavaFX cascading stylesheet file
113
workspace.ui.theme.custom.title=Path
114
115
workspace.ui.font=Fonts
116
workspace.ui.font.editor=Editor Font
117
workspace.ui.font.editor.name=Name
118
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
119
workspace.ui.font.editor.name.title=Family
120
workspace.ui.font.editor.size=Size
121
workspace.ui.font.editor.size.desc=Font size.
122
workspace.ui.font.editor.size.title=Points
123
workspace.ui.font.preview=Preview Font
124
workspace.ui.font.preview.name=Name
125
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
126
workspace.ui.font.preview.name.title=Family
127
workspace.ui.font.preview.size=Size
128
workspace.ui.font.preview.size.desc=Font size.
129
workspace.ui.font.preview.size.title=Points
130
workspace.ui.font.preview.mono.name=Name
131
workspace.ui.font.preview.mono.name.desc=Monospace font name.
132
workspace.ui.font.preview.mono.name.title=Family
133
workspace.ui.font.preview.mono.size=Size
134
workspace.ui.font.preview.mono.size.desc=Monospace font size.
135
workspace.ui.font.preview.mono.size.title=Points
136
137
workspace.language=Language
138
workspace.language.locale=Internationalization
139
workspace.language.locale.desc=Language for application and HTML export.
140
workspace.language.locale.title=Locale
141
142
# ########################################################################
143
# Definition Pane and its Tree View
144
# ########################################################################
145
146
Definition.menu.add.default=Undefined
147
148
# ########################################################################
149
# Definition Pane
150
# ########################################################################
151
152
Pane.definition.node.root.title=Definitions
153
154
# ########################################################################
155
# Failure messages with respect to YAML files.
156
# ########################################################################
157
158
yaml.error.open=Could not open YAML file (ensure non-empty file).
159
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
160
yaml.error.missing=Empty definition value for key ''{0}''.
161
yaml.error.tree.form=Unassigned definition near ''{0}''.
162
163
# ########################################################################
164
# Text Resource
165
# ########################################################################
166
167
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
168
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
169
170
# ########################################################################
171
# Text Resources
172
# ########################################################################
173
174
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
175
TextResource.saveFailed.title=Save
176
177
# ########################################################################
178
# File Open
179
# ########################################################################
180
181
Dialog.file.choose.open.title=Open File
182
Dialog.file.choose.save.title=Save File
183
Dialog.file.choose.export.title=Export File
184
185
Dialog.file.choose.filter.title.source=Source Files
186
Dialog.file.choose.filter.title.definition=Definition Files
187
Dialog.file.choose.filter.title.xml=XML Files
188
Dialog.file.choose.filter.title.all=All Files
189
190
# ########################################################################
191
# Browse File
192
# ########################################################################
193
194
BrowseFileButton.chooser.title=Browse for local file
195
BrowseFileButton.chooser.allFilesFilter=All Files
196
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
197
198
# ########################################################################
199
# Alert Dialog
200
# ########################################################################
201
202
Alert.file.close.title=Close
203
Alert.file.close.text=Save changes to {0}?
204
205
# ########################################################################
206
# Image Dialog
207
# ########################################################################
208
209
Dialog.image.title=Image
210
Dialog.image.chooser.imagesFilter=Images
211
Dialog.image.previewLabel.text=Markdown Preview\:
212
Dialog.image.textLabel.text=Alternate Text\:
213
Dialog.image.titleLabel.text=Title (tooltip)\:
214
Dialog.image.urlLabel.text=Image URL\:
215
216
# ########################################################################
217
# Hyperlink Dialog
218
# ########################################################################
219
220
Dialog.link.title=Link
221
Dialog.link.previewLabel.text=Markdown Preview\:
222
Dialog.link.textLabel.text=Link Text\:
223
Dialog.link.titleLabel.text=Title (tooltip)\:
224
Dialog.link.urlLabel.text=Link URL\:
225
226
# ########################################################################
227
# About Dialog
228
# ########################################################################
229
230
Dialog.about.title=About {0}
231
Dialog.about.header={0}
232
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
233
234
# ########################################################################
235
# Application Actions
236
# ########################################################################
237
238
App.action.file.new.description=Create a new file
239
App.action.file.new.accelerator=Shortcut+N
240
App.action.file.new.icon=FILE_ALT
241
App.action.file.new.text=_New
242
243
App.action.file.open.description=Open a new file
244
App.action.file.open.accelerator=Shortcut+O
245
App.action.file.open.text=_Open...
246
App.action.file.open.icon=FOLDER_OPEN_ALT
247
248
App.action.file.close.description=Close the current document
249
App.action.file.close.accelerator=Shortcut+W
250
App.action.file.close.text=_Close
251
252
App.action.file.close_all.description=Close all open documents
253
App.action.file.close_all.accelerator=Ctrl+F4
254
App.action.file.close_all.text=Close All
255
256
App.action.file.save.description=Save the document
257
App.action.file.save.accelerator=Shortcut+S
258
App.action.file.save.text=_Save
259
App.action.file.save.icon=FLOPPY_ALT
260
261
App.action.file.save_as.description=Rename the current document
262
App.action.file.save_as.text=Save _As
263
264
App.action.file.save_all.description=Save all open documents
265
App.action.file.save_all.accelerator=Shortcut+Shift+S
266
App.action.file.save_all.text=Save A_ll
267
268
App.action.file.export.html_svg.description=Export the current document as HTML + SVG
269
App.action.file.export.text=_Export As
270
App.action.file.export.html_svg.text=HTML and S_VG
271
272
App.action.file.export.html_tex.description=Export the current document as HTML + TeX
273
App.action.file.export.html_tex.text=HTML and _TeX
274
275
App.action.file.export.markdown.description=Export the current document as Markdown
276
App.action.file.export.markdown.text=Markdown
277
278
App.action.file.exit.description=Quit the application
279
App.action.file.exit.text=E_xit
280
281
282
App.action.edit.undo.description=Undo the previous edit
283
App.action.edit.undo.accelerator=Shortcut+Z
284
App.action.edit.undo.text=_Undo
285
App.action.edit.undo.icon=UNDO
286
287
App.action.edit.redo.description=Redo the previous edit
288
App.action.edit.redo.accelerator=Shortcut+Y
289
App.action.edit.redo.text=_Redo
290
App.action.edit.redo.icon=REPEAT
291
292
App.action.edit.cut.description=Delete the selected text or line
293
App.action.edit.cut.accelerator=Shortcut+X
294
App.action.edit.cut.text=Cu_t
295
App.action.edit.cut.icon=CUT
296
297
App.action.edit.copy.description=Copy the selected text
298
App.action.edit.copy.accelerator=Shortcut+C
299
App.action.edit.copy.text=_Copy
300
App.action.edit.copy.icon=COPY
301
302
App.action.edit.paste.description=Paste from the clipboard
303
App.action.edit.paste.accelerator=Shortcut+V
304
App.action.edit.paste.text=_Paste
305
App.action.edit.paste.icon=PASTE
306
307
App.action.edit.select_all.description=Highlight the current document text
308
App.action.edit.select_all.accelerator=Shortcut+A
309
App.action.edit.select_all.text=Select _All
310
311
App.action.edit.find.description=Search for text in the document
312
App.action.edit.find.accelerator=Shortcut+F
313
App.action.edit.find.text=_Find
314
App.action.edit.find.icon=SEARCH
315
316
App.action.edit.find_next.description=Find next occurrence
317
App.action.edit.find_next.accelerator=F3
318
App.action.edit.find_next.text=Find _Next
319
320
App.action.edit.find_prev.description=Find previous occurrence
321
App.action.edit.find_prev.accelerator=Shift+F3
322
App.action.edit.find_prev.text=Find _Prev
323
324
App.action.edit.preferences.description=Edit user preferences
325
App.action.edit.preferences.accelerator=Ctrl+Alt+S
326
App.action.edit.preferences.text=_Preferences
327
328
329
App.action.format.bold.description=Insert strong text
330
App.action.format.bold.accelerator=Shortcut+B
331
App.action.format.bold.text=_Bold
332
App.action.format.bold.icon=BOLD
333
334
App.action.format.italic.description=Insert text emphasis
335
App.action.format.italic.accelerator=Shortcut+I
336
App.action.format.italic.text=_Italic
337
App.action.format.italic.icon=ITALIC
338
339
App.action.format.superscript.description=Insert superscript text
340
App.action.format.superscript.accelerator=Shortcut+[
341
App.action.format.superscript.text=Su_perscript
342
App.action.format.superscript.icon=SUPERSCRIPT
343
344
App.action.format.subscript.description=Insert subscript text
345
App.action.format.subscript.accelerator=Shortcut+]
346
App.action.format.subscript.text=Su_bscript
347
App.action.format.subscript.icon=SUBSCRIPT
348
349
App.action.format.strikethrough.description=Insert struck text
350
App.action.format.strikethrough.accelerator=Shortcut+T
351
App.action.format.strikethrough.text=Stri_kethrough
352
App.action.format.strikethrough.icon=STRIKETHROUGH
353
354
355
App.action.insert.blockquote.description=Insert blockquote
356
App.action.insert.blockquote.accelerator=Ctrl+Q
357
App.action.insert.blockquote.text=_Blockquote
358
App.action.insert.blockquote.icon=QUOTE_LEFT
359
360
App.action.insert.code.description=Insert inline code
361
App.action.insert.code.accelerator=Shortcut+K
362
App.action.insert.code.text=Inline _Code
363
App.action.insert.code.icon=CODE
364
365
App.action.insert.fenced_code_block.description=Insert code block
366
App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
367
App.action.insert.fenced_code_block.text=_Fenced Code Block
368
App.action.insert.fenced_code_block.prompt.text=Enter code here
369
App.action.insert.fenced_code_block.icon=FILE_CODE_ALT
370
371
App.action.insert.link.description=Insert hyperlink
372
App.action.insert.link.accelerator=Shortcut+L
373
App.action.insert.link.text=_Link...
374
App.action.insert.link.icon=LINK
375
376
App.action.insert.image.description=Insert image
377
App.action.insert.image.accelerator=Shortcut+G
378
App.action.insert.image.text=_Image...
379
App.action.insert.image.icon=PICTURE_ALT
380
381
App.action.insert.heading.description=Insert heading level
382
App.action.insert.heading.accelerator=Shortcut+
383
App.action.insert.heading.icon=HEADER
384
385
App.action.insert.heading_1.description=${App.action.insert.heading.description} 1
386
App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1
387
App.action.insert.heading_1.text=Heading _1
388
App.action.insert.heading_1.icon=${App.action.insert.heading.icon}
389
390
App.action.insert.heading_2.description=${App.action.insert.heading.description} 2
391
App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2
392
App.action.insert.heading_2.text=Heading _2
393
App.action.insert.heading_2.icon=${App.action.insert.heading.icon}
394
395
App.action.insert.heading_3.description=${App.action.insert.heading.description} 3
396
App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3
397
App.action.insert.heading_3.text=Heading _3
398
App.action.insert.heading_3.icon=${App.action.insert.heading.icon}
399
400
App.action.insert.unordered_list.description=Insert bulleted list
401
App.action.insert.unordered_list.accelerator=Shortcut+U
402
App.action.insert.unordered_list.text=_Unordered List
403
App.action.insert.unordered_list.icon=LIST_UL
404
405
App.action.insert.ordered_list.description=Insert enumerated list
406
App.action.insert.ordered_list.accelerator=Shortcut+Shift+O
407
App.action.insert.ordered_list.text=_Ordered List
408
App.action.insert.ordered_list.icon=LIST_OL
409
410
App.action.insert.horizontal_rule.description=Insert horizontal rule
411
App.action.insert.horizontal_rule.accelerator=Shortcut+H
412
App.action.insert.horizontal_rule.text=_Horizontal Rule
413
App.action.insert.horizontal_rule.icon=LIST_OL
414
415
416
App.action.definition.create.description=Create a new variable definition
417
App.action.definition.create.text=_Create
418
App.action.definition.create.icon=TREE
419
App.action.definition.create.tooltip=Add new item (Insert)
420
421
App.action.definition.rename.description=Rename the selected variable definition
422
App.action.definition.rename.text=_Rename
423
App.action.definition.rename.icon=EDIT
424
App.action.definition.rename.tooltip=Rename selected item (F2)
425
426
App.action.definition.delete.description=Delete the selected variable definitions
427
App.action.definition.delete.text=De_lete
420428
App.action.definition.delete.icon=TRASH
421429
App.action.definition.delete.tooltip=Delete selected items (Delete)
D src/main/resources/com/keenwrite/scene.css
1
/*
2
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
27
28
.tool-bar {
29
	-fx-spacing: 0;
30
}
31
32
.tool-bar .button {
33
	-fx-background-color: transparent;
34
}
35
36
.tool-bar .button:hover {
37
	-fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color;
38
	-fx-color: -fx-hover-base;
39
}
40
41
.tool-bar .button:armed {
42
	-fx-color: -fx-pressed-base;
43
}
44
45
/* Definition editor drag and drop target.
46
 */
47
.drop-target {
48
  -fx-border-color: #eea82f;
49
  -fx-border-width: 0 0 2 0;
50
  -fx-padding: 3 3 1 3
51
}
521
M src/main/resources/com/keenwrite/settings.properties
2424
# ########################################################################
2525
26
#file.stylesheet.dock=com/panemu/tiwulfx/control/dock/tiwulfx-dock.css
27
file.stylesheet.scene=${application.package}/scene.css
26
file.stylesheet.application.dir=${application.package}/themes
27
file.stylesheet.application.base=${file.stylesheet.application.dir}/scene.css
28
file.stylesheet.application.theme=${file.stylesheet.application.dir}/{0}.css
2829
file.stylesheet.markdown=${application.package}/editor/markdown.css
2930
# {0} language code, {1} script code, {2} country code
A src/main/resources/com/keenwrite/themes/count_darcula.css
1
.root {
2
  -fx-base: rgb( 43, 43, 43 );
3
  -fx-background: -fx-base;
4
  -fx-control-inner-background: -fx-base;
5
6
  -fx-light-text-color: rgb( 187, 187, 187 );
7
  -fx-mid-text-color: derive( -fx-base, 100% );
8
  -fx-dark-text-color: derive( -fx-base, 25% );
9
  -fx-text-foreground: -fx-light-text-color;
10
  -fx-text-background: derive( -fx-control-inner-background, 7.5% );
11
  -fx-text-selection: derive( -fx-control-inner-background, 45% );
12
13
  /* Make controls ( buttons, thumb, etc. ) slightly lighter */
14
  -fx-color: derive( -fx-base, 20% );
15
}
16
17
.caret {
18
  -fx-stroke: -fx-accent;
19
}
20
21
.glyph-icon {
22
  -fx-text-fill: -fx-light-text-color;
23
  -fx-fill: -fx-light-text-color;
24
}
25
26
.glyph-icon:hover {
27
  -fx-effect: dropshadow( three-pass-box, rgba( 0, 0, 0, 0.2 ), 4, 0, 0, 0 );
28
}
29
30
/* Fix derived prompt color for text fields */
31
.text-input {
32
  -fx-prompt-text-fill: derive( -fx-control-inner-background, +50% );
33
}
34
35
/* Keep prompt invisible when focused ( above color fix overrides it ) */
36
.text-input:focused {
37
  -fx-prompt-text-fill: transparent;
38
}
39
40
/* Fix scroll bar buttons arrows colors */
41
.scroll-bar > .increment-button > .increment-arrow,
42
.scroll-bar > .decrement-button > .decrement-arrow {
43
  -fx-background-color: -fx-mark-highlight-color,  -fx-light-text-color;
44
}
45
46
.scroll-bar > .increment-button:hover > .increment-arrow,
47
.scroll-bar > .decrement-button:hover > .decrement-arrow {
48
  -fx-background-color: -fx-mark-highlight-color, rgb( 240, 240, 240 );
49
}
50
51
.scroll-bar > .increment-button:pressed > .increment-arrow,
52
.scroll-bar > .decrement-button:pressed > .decrement-arrow {
53
  -fx-background-color: -fx-mark-highlight-color, rgb( 255, 255, 255 );
54
}
155
A src/main/resources/com/keenwrite/themes/haunted_grey.css
1
/* https://stackoverflow.com/a/58441758/59087
2
 */
3
.root { 
4
  -fx-accent: #1e74c6;
5
  -fx-focus-color: -fx-accent;
6
  -fx-base: #373e43;
7
  -fx-control-inner-background: derive( -fx-base, 35% );
8
  -fx-control-inner-background-alt: -fx-control-inner-background;
9
10
  -fx-light-text-color: derive( -fx-base, 150% );
11
  -fx-mid-text-color: derive( -fx-base, 100% );
12
  -fx-dark-text-color: derive( -fx-base, 25% );
13
  -fx-text-foreground: -fx-light-text-color;
14
  -fx-text-background: derive( -fx-control-inner-background, 7.5% );
15
  -fx-text-selection: derive( -fx-control-inner-background, 45% );
16
}
17
18
.glyph-icon {
19
  -fx-text-fill: -fx-light-text-color;
20
  -fx-fill: -fx-light-text-color;
21
}
22
23
.glyph-icon:hover {
24
  -fx-effect: dropshadow( three-pass-box, rgba( 0, 0, 0, 0.2 ), 4, 0, 0, 0 );
25
}
26
27
.label {
28
  -fx-text-fill: -fx-light-text-color;
29
}
30
31
.text-field {
32
  -fx-prompt-text-fill: gray;
33
}
34
35
.button {
36
  -fx-focus-traversable: false;
37
}
38
39
.button:hover {
40
  -fx-text-fill: white;
41
}
42
43
.separator *.line { 
44
  -fx-background-color: #3C3C3C;
45
  -fx-border-style: solid;
46
  -fx-border-width: 1px;
47
}
48
49
.scroll-bar {
50
  -fx-background-color: derive( -fx-base, 45% );
51
}
52
53
.button:default {
54
  -fx-base: -fx-accent;
55
} 
56
57
.table-view {
58
  -fx-selection-bar-non-focused: derive( -fx-base, 50% );
59
}
60
61
.table-view .column-header .label {
62
  -fx-alignment: CENTER_LEFT;
63
  -fx-font-weight: none;
64
}
65
66
.list-cell:even,
67
.list-cell:odd,
68
.table-row-cell:even,
69
.table-row-cell:odd {  
70
  -fx-control-inner-background: derive( -fx-base, 15% );
71
}
72
73
.list-cell:empty,
74
.table-row-cell:empty {
75
  -fx-background-color: transparent;
76
}
77
78
.list-cell,
79
.table-row-cell {
80
  -fx-border-color: transparent;
81
  -fx-table-cell-border-color: transparent;
82
}
183
A src/main/resources/com/keenwrite/themes/modena_dark.css
1
/* https://github.com/joffrey-bion/javafx-themes/blob/master/css/modena_dark.css
2
 */
3
.root {
4
  -fx-base: rgb( 50, 50, 50 );
5
  -fx-background: -fx-base;
6
7
  /* Make controls ( buttons, thumb, etc. ) slightly lighter */
8
  -fx-color: derive( -fx-base, 10% );
9
10
  /* Text fields and table rows background */
11
  -fx-control-inner-background: rgb( 20, 20, 20 );
12
  /* Version of -fx-control-inner-background for alternative rows */
13
  -fx-control-inner-background-alt: derive( -fx-control-inner-background, 2.5% );
14
15
  /* Text colors depending on background's brightness */
16
  -fx-light-text-color: rgb( 220, 220, 220 );
17
  -fx-mid-text-color: rgb( 100, 100, 100 );
18
  -fx-dark-text-color: rgb( 20, 20, 20 );
19
  -fx-text-foreground: -fx-light-text-color;
20
  -fx-text-background: derive( -fx-control-inner-background, 7.5% );
21
  -fx-text-selection: derive( -fx-control-inner-background, 45% );
22
23
  /* A bright blue for highlighting/accenting objects.  For example: selected
24
   * text; selected items in menus, lists, trees, and tables; progress bars */
25
  -fx-accent: rgb( 0, 80, 100 );
26
27
  /* Color of non-focused yet selected elements */
28
  -fx-selection-bar-non-focused: rgb( 50, 50, 50 );
29
}
30
31
.glyph-icon {
32
  -fx-text-fill: -fx-light-text-color;
33
  -fx-fill: -fx-light-text-color;
34
}
35
36
.glyph-icon:hover {
37
  -fx-effect: dropshadow( three-pass-box, rgba( 0, 0, 0, 0.2 ), 4, 0, 0, 0 );
38
}
39
40
/* Fix derived prompt color for text fields */
41
.text-input {
42
  -fx-prompt-text-fill: derive( -fx-control-inner-background, +50% );
43
}
44
45
/* Keep prompt invisible when focused ( above color fix overrides it ) */
46
.text-input:focused {
47
  -fx-prompt-text-fill: transparent;
48
}
49
50
/* Fix scroll bar buttons arrows colors */
51
.scroll-bar > .increment-button > .increment-arrow,
52
.scroll-bar > .decrement-button > .decrement-arrow {
53
  -fx-background-color: -fx-mark-highlight-color, rgb( 220, 220, 220 );
54
}
55
56
.scroll-bar > .increment-button:hover > .increment-arrow,
57
.scroll-bar > .decrement-button:hover > .decrement-arrow {
58
  -fx-background-color: -fx-mark-highlight-color, rgb( 240, 240, 240 );
59
}
60
61
.scroll-bar > .increment-button:pressed > .increment-arrow,
62
.scroll-bar > .decrement-button:pressed > .decrement-arrow {
63
  -fx-background-color: -fx-mark-highlight-color, rgb( 255, 255, 255 );
64
}
165
A src/main/resources/com/keenwrite/themes/modena_light.css
1
.root {
2
  -fx-text-foreground: -fx-dark-text-color;
3
  -fx-text-background: derive( -fx-accent, 124% );
4
  -fx-text-selection: #a6d2ff;
5
}
16
A src/main/resources/com/keenwrite/themes/scene.css
1
/*
2
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
3
 * All rights reserved.
4
 *
5
 * Redistribution and use in source and binary forms, with or without
6
 * modification, are permitted provided that the following conditions are met:
7
 *
8
 *  o Redistributions of source code must retain the above copyright
9
 *    notice, this list of conditions and the following disclaimer.
10
 *
11
 *  o Redistributions in binary form must reproduce the above copyright
12
 *    notice, this list of conditions and the following disclaimer in the
13
 *    documentation and/or other materials provided with the distribution.
14
 *
15
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
18
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
19
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
20
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
 */
27
28
.tool-bar {
29
  -fx-spacing: 0;
30
}
31
32
.tool-bar .button {
33
  -fx-background-color: transparent;
34
}
35
36
.tool-bar .button:hover {
37
  -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color;
38
  -fx-color: -fx-hover-base;
39
}
40
41
.tool-bar .button:armed {
42
  -fx-color: -fx-pressed-base;
43
}
44
45
/* Definition editor drag and drop target.
46
 */
47
.drop-target {
48
  -fx-border-color: #eea82f;
49
  -fx-border-width: 0 0 2 0;
50
  -fx-padding: 3 3 1 3
51
}
152
A src/main/resources/com/keenwrite/themes/silver_cavern.css
1
/* https://toedter.com/2011/10/26/java-fx-2-0-css-styling/
2
 */
3
.root {
4
  -fx-base: rgb( 50, 50, 50 );
5
  -fx-background: -fx-base;
6
  -fx-control-inner-background: -fx-base;
7
8
  -fx-light-text-color: derive( -fx-base, 150% );
9
  -fx-mid-text-color: derive( -fx-base, 100% );
10
  -fx-dark-text-color: derive( -fx-base, 25% );
11
  -fx-text-foreground: -fx-light-text-color;
12
  -fx-text-background: derive( -fx-control-inner-background, 7.5% );
13
  -fx-text-selection: derive( -fx-control-inner-background, 45% );
14
}
15
16
.glyph-icon {
17
  -fx-text-fill: -fx-light-text-color;
18
  -fx-fill: -fx-light-text-color;
19
}
20
21
.glyph-icon:hover {
22
  -fx-effect: dropshadow( three-pass-box, rgba( 0, 0, 0, 0.2 ), 4, 0, 0, 0 );
23
}
24
 
25
.tab {
26
  -fx-background-color: linear-gradient( to top, -fx-base, derive( -fx-base, 30% ) );
27
}
28
29
.menu-bar {
30
  -fx-background-color: linear-gradient( to bottom, -fx-base, derive( -fx-base, 30% ) );
31
}
32
 
33
.tool-bar:horizontal {
34
  -fx-background-color: linear-gradient( to bottom, derive( -fx-base, +50% ), derive( -fx-base, -40% ), derive( -fx-base, -20% ) );
35
}
36
 
37
.button {
38
  -fx-background-color: transparent;
39
}
40
 
41
.button:hover {
42
  -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color;
43
  -fx-color: -fx-hover-base;
44
}
45
 
46
.table-view {
47
  -fx-table-cell-border-color:derive( -fx-base, +10% );
48
  -fx-table-header-border-color:derive( -fx-base, +20% );
49
}
50
 
51
.split-pane:horizontal > * > .split-pane-divider {
52
  -fx-border-color: transparent -fx-base transparent -fx-base;
53
  -fx-background-color: transparent, derive( -fx-base, 20% );
54
  -fx-background-insets: 0, 0 1 0 1;
55
}
56
57
.separator-label {
58
  -fx-text-fill: orange;
59
}
160
A src/main/resources/com/keenwrite/themes/solarized_dark.css
1
/* https://ethanschoonover.com/solarized
2
 */
3
.root {
4
  /* Solarized: base03 */
5
  -fx-base: rgb( 0, 43, 54 );
6
  -fx-background: -fx-base;
7
8
  /* Brighten controls */
9
  -fx-color: derive( -fx-base, -40% );
10
11
  -fx-control-inner-background: -fx-base;
12
  -fx-control-inner-background-alt: derive( -fx-control-inner-background, 2.5% );
13
14
  /* Text colors */
15
  /* Solarized: base0 */
16
  -fx-light-text-color: rgb( 131, 148, 150 );
17
  -fx-mid-text-color: derive( -fx-light-text-color, 50% );
18
  -fx-dark-text-color: derive( -fx-light-text-color, 25% );
19
  -fx-text-foreground: -fx-light-text-color;
20
  -fx-text-background: derive( -fx-control-inner-background, 7.5% );
21
  -fx-text-selection: derive( -fx-control-inner-background, 45% );
22
23
  -fx-mid-text-color: derive( -fx-base, 100% );
24
  -fx-dark-text-color: derive( -fx-base, 25% );
25
  -fx-text-foreground: -fx-light-text-color;
26
  -fx-text-background: derive( -fx-control-inner-background, 7.5% );
27
  -fx-text-selection: derive( -fx-control-inner-background, 45% );
28
29
  /* Accent colors */
30
  -fx-accent: rgb( 38, 139, 210 );
31
  -fx-focus-color: rgb( 253, 246, 227 );
32
33
  /* Non-focused-selected elements */
34
  -fx-selection-bar-non-focused: rgb( 0, 43, 54 );
35
}
36
37
.glyph-icon {
38
  -fx-text-fill: -fx-light-text-color;
39
  -fx-fill: -fx-light-text-color;
40
}
41
42
.glyph-icon:hover {
43
  -fx-effect: dropshadow( three-pass-box, rgba( 0, 0, 0, 0.2 ), 4, 0, 0, 0 );
44
}
45
46
.scroll-bar {
47
  -fx-background-color: derive( -fx-base, 45% );
48
}
49
50
.caret {
51
  -fx-stroke: -fx-accent;
52
}
53
154
A src/main/resources/com/keenwrite/themes/vampire_byte.css
1
/* https://github.com/Col-E/Recaf/blob/master/src/main/resources/style/ui-dark.css
2
 */
3
.root {
4
  -fx-base: rgb( 45, 45, 46 );
5
  -fx-background: -fx-base;
6
7
  /* Brighten controls */
8
  -fx-color: derive( -fx-base, -40% );
9
10
  /* Control background */
11
  -fx-control-inner-background: rgb( 46, 46, 47 );
12
13
  /* Alternative control background ( rows ) */
14
  -fx-control-inner-background-alt: derive( -fx-control-inner-background, 2.5% );
15
16
  /* Text colors */
17
  -fx-light-text-color: rgb( 220, 220, 220 );
18
  -fx-mid-text-color: rgb( 100, 100, 100 );
19
  -fx-dark-text-color: rgb( 20, 20, 20 );
20
  -fx-text-foreground: -fx-light-text-color;
21
  -fx-text-background: derive( -fx-control-inner-background, 7.5% );
22
  -fx-text-selection: derive( -fx-control-inner-background, 45% );
23
24
  /* Accent colors */
25
  -fx-accent: rgb( 51, 51, 52 );
26
  -fx-focus-color: rgb( 51, 51, 52 );
27
28
  /* Non-focused-selected elements */
29
  -fx-selection-bar-non-focused: rgb( 45, 45, 46 );
30
}
31
32
.glyph-icon {
33
  -fx-text-fill: -fx-light-text-color;
34
  -fx-fill: -fx-light-text-color;
35
}
36
37
.glyph-icon:hover {
38
  -fx-effect: dropshadow( three-pass-box, rgba( 0, 0, 0, 0.2 ), 4, 0, 0, 0 );
39
}
40
41
* {
42
  -fx-highlight-fill: rgba( 0, 180, 255, 0.4 );
43
}
44
45
/* Scroll */
46
.scroll-bar {
47
  -fx-background-color: rgb( 61,61,62 );
48
}
49
.scroll-bar .thumb {
50
  -fx-background-color: rgb( 91,91,92 );
51
  -fx-background-radius: 0;
52
}
53
.scroll-bar .thumb:hover,
54
.scroll-bar .thumb:pressed {
55
  -fx-background-color: rgb( 141,141,142 );
56
}
57
.scroll-bar .increment-button .increment-arrow,
58
.scroll-bar .decrement-button .decrement-arrow {
59
  -fx-background-color: rgb( 200,200,200 );
60
}
61
.corner {
62
  -fx-background-color: rgb( 61,61,62 );
63
}
64
65
/* Menu */
66
.menu-bar {
67
  -fx-background-color: rgb( 45, 45, 48 );
68
}
69
.menu {
70
  -fx-padding: 6 14 6 14;
71
  -fx-background-insets: -1;
72
}
73
.menu-item {
74
  -fx-padding: 5 11 5 11;
75
  -fx-background-insets: -1;
76
}
77
.menu:hover {
78
  -fx-background-color: rgb( 61, 61, 62 );
79
}
80
.context-menu,
81
.menu:showing {
82
  -fx-background-color: rgb( 27, 27, 28 );
83
  -fx-border-insets: -1;
84
  -fx-border-width: 1;
85
  -fx-border-color: black;
86
}
87
.context-menu {
88
  -fx-min-width: 80px;
89
  -fx-background-insets: -1;
90
  -fx-border-insets: -1;
91
  -fx-border-width: 1;
92
  -fx-border-color: black;
93
}
94
.context-menu .menu-item:focused {
95
  -fx-background-color: rgb( 61, 61, 62 );
96
}
97
.context-menu-header {
98
  /* TODO: Find a way to disable hover coloring on the menu header */
99
  -fx-opacity: 1.0;
100
  -fx-background-color: rgb( 24, 50, 95 );
101
}
102
.context-menu-header .label {
103
  -fx-opacity: 1.0;
104
}
105
106
/* Tabs */
107
.tab-pane {
108
  -fx-tab-min-width: 100px;
109
}
110
.tab-pane *.tab-header-background {
111
  -fx-background-color: rgb( 29, 29, 31 );
112
  -fx-border-width: 0 0 1 0;
113
  -fx-border-color: black;
114
}
115
.headers-region {
116
  -fx-background-color: rgb( 75, 75, 76 );
117
}
118
.tab {
119
  -fx-background-color: rgb( 36,36,37 );
120
  -fx-background-insets: 2 -1 -1 -1;
121
  -fx-background-radius: 0;
122
  -fx-padding: 2 2 1 2;
123
  -fx-border-insets: 0;
124
  -fx-border-width: 1 1 1 1;
125
  -fx-border-color: black;
126
}
127
.tab:selected {
128
  -fx-background-color: rgb( 45, 45, 46 );
129
  -fx-background-insets: 2 -1 -1 -1;
130
  -fx-padding: 2;
131
  -fx-border-insets: 0;
132
  -fx-border-width: 1 1 0 1;
133
  -fx-border-color: black;
134
}
135
.tab:selected .focus-indicator {
136
  -fx-border-color: transparent;
137
}
138
139
/* Table */
140
.table-view {
141
  -fx-selection-bar: rgb( 50, 71, 77 );
142
  -fx-selection-bar-non-focused: rgb( 46, 56, 59 );
143
  -fx-background-color: rgb( 36,36,37 );
144
  -fx-background-insets: 2 -1 -1 -1;
145
  -fx-background-radius: 0;
146
  -fx-padding: -1;
147
  -fx-border-width: 0 1 1 1;
148
  -fx-border-color: rgb( 22, 22, 23 );
149
}
150
.table-view .filler,
151
.table-view .show-hide-columns-button,
152
.column-overlay {
153
  -fx-background-color: transparent;
154
}
155
.column-header-background {
156
  -fx-background-color: rgb( 36,36,37 );
157
  -fx-background-insets: 2 -1 -1 -1;
158
  -fx-padding: -1;
159
  -fx-border-insets: 0;
160
  -fx-border-width: 0 1 0 1;
161
  -fx-border-color: rgb( 22, 22, 23 );
162
}
163
.column-header {
164
  -fx-background-color: rgb( 45, 45, 46 );
165
  -fx-background-insets: -1 -0 -1 0;
166
  -fx-padding: 2;
167
  -fx-border-insets: 1 -1 1 0;
168
  -fx-border-width: 1;
169
  -fx-border-color: rgb( 22, 22, 23 );
170
}
171
172
/* Splitpane */
173
.split-pane-divider {
174
  -fx-background-color: black;
175
  -fx-padding: 0;
176
  -fx-background-insets: -5;
177
}
178
179
/* Tree */
180
.tree-table-view,
181
.tree-view {
182
  -fx-background-color: rgb( 29, 29, 31 );
183
  -fx-background-insets: 0;
184
  -fx-border-width: 0 1 0 0;
185
  -fx-border-color: black;
186
}
187
.tree-table-cell,
188
.tree-cell {
189
  -fx-background-color: rgb( 29, 29, 31 );
190
}
191
.tree-cell:selected {
192
  -fx-background-color: rgb( 44, 48, 55 );
193
}
194
195
/* Buttons */
196
.box,
197
.button,
198
.combo-box,
199
.slider .thumb {
200
  -fx-background-radius: 0;
201
  -fx-background-color: rgb( 63, 63, 70 );
202
  -fx-background-insets: 0;
203
  -fx-border-width: 1;
204
  -fx-border-color: rgb( 85, 85, 85 );
205
}
206
.check-box:hover .box,
207
.button:hover,
208
.combo-box:hover,
209
.slider .thumb:hover {
210
  -fx-background-color: rgb( 80, 80, 85 );
211
  -fx-border-color: rgb( 0, 122, 205 );
212
}
213
.check-box:pressed .box,
214
.button:pressed,
215
.combo-box:pressed,
216
.slider .thumb:pressed {
217
  -fx-background-color: rgb( 0, 122, 205 );
218
  -fx-border-color: rgb( 0, 162, 245 );
219
}
220
.combo-box:showing {
221
  -fx-background-color: rgb( 27, 27, 28 );
222
  -fx-border-width: 1 1 0 1;
223
  -fx-border-color: black;
224
}
225
.combo-box .combo-box-popup .list-cell {
226
  -fx-background-color: rgb( 27, 27, 28 );
227
}
228
.combo-box .combo-box-popup .list-cell:hover {
229
  -fx-background-color: rgb( 61, 61, 62 );
230
}
231
.combo-box .combo-box-popup .list-view {
232
  -fx-background-color: rgb( 27, 27, 28 );
233
  -fx-border-width: 0 1 1 1;
234
  -fx-border-color: black;
235
}
236
.hyperlink {
237
  -fx-text-fill: rgb( 30, 132, 250 );
238
}
239
hyperlink:visited {
240
  -fx-text-fill: rgb( 98, 59, 217 );
241
}
242
243
/* slider */
244
.slider .track {
245
  -fx-background-radius: 0;
246
  -fx-background-color: rgb( 29, 29, 31 );
247
  -fx-background-insets: 0;
248
  -fx-border-width: 1;
249
  -fx-border-color: rgb( 65, 65, 65 );
250
}
251
.slider .thumb {
252
  -fx-padding: 5;
253
}
254
.axis-tick-mark {
255
  -fx-stroke: rgb( 100, 100, 100 );
256
}
257
258
/* Text */
259
.text-area .content,
260
.text-field {
261
  -fx-background-radius: 0;
262
  -fx-background-color: rgb( 63, 63, 70 );
263
  -fx-background-insets: 0;
264
  -fx-border-width: 1;
265
  -fx-border-color: rgb( 85, 85, 85 );
266
}
267
.text-area {
268
  -fx-background-radius: 0;
269
  -fx-background-color: rgb( 63, 63, 70 );
270
  -fx-background-insets: 0;
271
  -fx-border-width: 1;
272
  -fx-border-color: rgb( 85, 85, 85 );
273
}
274
.text-area .content {
275
  -fx-border-width: 0;
276
}
277
278
/* Popup */
279
.tooltip {
280
  -fx-background-radius: 0;
281
  -fx-background-color: rgb( 40, 40, 42 );
282
  -fx-background-insets: 0;
283
  -fx-border-width: 1;
284
  -fx-border-color: rgb( 70, 70, 72 );
285
}
1286
D src/main/resources/styles/dark/charcoal.css
1
.root { 
2
    -fx-accent: #1e74c6;
3
    -fx-focus-color: -fx-accent;
4
    -fx-base: #373e43;
5
    -fx-control-inner-background: derive(-fx-base, 35%);
6
    -fx-control-inner-background-alt: -fx-control-inner-background ;
7
}
8
9
.label{
10
    -fx-text-fill: lightgray;
11
}
12
13
.text-field {
14
    -fx-prompt-text-fill: gray;
15
}
16
17
.titulo{
18
    -fx-font-weight: bold;
19
    -fx-font-size: 18px;
20
}
21
22
.button{
23
    -fx-focus-traversable: false;
24
}
25
26
.button:hover{
27
    -fx-text-fill: white;
28
}
29
30
.separator *.line { 
31
    -fx-background-color: #3C3C3C;
32
    -fx-border-style: solid;
33
    -fx-border-width: 1px;
34
}
35
36
.scroll-bar{
37
    -fx-background-color: derive(-fx-base,45%)
38
}
39
40
.button:default {
41
    -fx-base: -fx-accent ;
42
} 
43
44
.table-view{
45
    /*-fx-background-color: derive(-fx-base, 10%);*/
46
    -fx-selection-bar-non-focused: derive(-fx-base, 50%);
47
}
48
49
.table-view .column-header .label{
50
    -fx-alignment: CENTER_LEFT;
51
    -fx-font-weight: none;
52
}
53
54
.list-cell:even,
55
.list-cell:odd,
56
.table-row-cell:even,
57
.table-row-cell:odd{    
58
    -fx-control-inner-background: derive(-fx-base, 15%);
59
}
60
61
.list-cell:empty,
62
.table-row-cell:empty {
63
    -fx-background-color: transparent;
64
}
65
66
.list-cell,
67
.table-row-cell{
68
    -fx-border-color: transparent;
69
    -fx-table-cell-border-color:transparent;
70
}
71
721
D src/main/resources/styles/dark/modena.css
1
/*
2
 * This is an adjustment of the original modena.css for a consistent dark theme.
3
 * Original modena.css here: https://gist.github.com/maxd/63691840fc372f22f470.
4
 */
5
6
/* Redefine base colors */
7
.root {
8
    -fx-base: rgb(50, 50, 50);
9
    -fx-background: rgb(50, 50, 50);
10
11
    /* make controls (buttons, thumb, etc.) slightly lighter */
12
    -fx-color: derive(-fx-base, 10%);
13
14
    /* text fields and table rows background */
15
    -fx-control-inner-background: rgb(20, 20, 20);
16
    /* version of -fx-control-inner-background for alternative rows */
17
    -fx-control-inner-background-alt: derive(-fx-control-inner-background, 2.5%);
18
19
    /* text colors depending on background's brightness */
20
    -fx-light-text-color: rgb(220, 220, 220);
21
    -fx-mid-text-color: rgb(100, 100, 100);
22
    -fx-dark-text-color: rgb(20, 20, 20);
23
24
    /* A bright blue for highlighting/accenting objects.  For example: selected
25
     * text; selected items in menus, lists, trees, and tables; progress bars */
26
    -fx-accent: rgb(0, 80, 100);
27
28
    /* color of non-focused yet selected elements */
29
    -fx-selection-bar-non-focused: rgb(50, 50, 50);
30
}
31
32
/* Fix derived prompt color for text fields */
33
.text-input {
34
    -fx-prompt-text-fill: derive(-fx-control-inner-background, +50%);
35
}
36
37
/* Keep prompt invisible when focused (above color fix overrides it) */
38
.text-input:focused {
39
    -fx-prompt-text-fill: transparent;
40
}
41
42
/* Fix scroll bar buttons arrows colors */
43
.scroll-bar > .increment-button > .increment-arrow,
44
.scroll-bar > .decrement-button > .decrement-arrow {
45
    -fx-background-color: -fx-mark-highlight-color, rgb(220, 220, 220);
46
}
47
48
.scroll-bar > .increment-button:hover > .increment-arrow,
49
.scroll-bar > .decrement-button:hover > .decrement-arrow {
50
    -fx-background-color: -fx-mark-highlight-color, rgb(240, 240, 240);
51
}
521
53
.scroll-bar > .increment-button:pressed > .increment-arrow,
54
.scroll-bar > .decrement-button:pressed > .decrement-arrow {
55
    -fx-background-color: -fx-mark-highlight-color, rgb(255, 255, 255);
56
}
D src/main/resources/styles/dark/recaf.css
1
/* =========================
2
 * ==     JFX Controls    ==
3
 * =========================
4
 */
5
.root {
6
	-fx-base: rgb(45, 45, 46);
7
	-fx-background: rgb(45, 45, 46);
8
	/* Brighten controls */
9
	-fx-color: derive(-fx-base, -40%);
10
	/* Control background */
11
	-fx-control-inner-background: rgb(46, 46, 47);
12
	/* Alternative control background (rows) */
13
	-fx-control-inner-background-alt: derive(-fx-control-inner-background, 2.5%);
14
	/* Text colors */
15
	-fx-light-text-color: rgb(220, 220, 220);
16
	-fx-mid-text-color: rgb(100, 100, 100);
17
	-fx-dark-text-color: rgb(20, 20, 20);
18
	/* Accent colors */
19
	-fx-accent: rgb(51, 51, 52);
20
	-fx-focus-color: rgb(51, 51, 52);
21
	/* Non-focused-selected elements */
22
	-fx-selection-bar-non-focused: rgb(45, 45, 46);
23
}
24
* {
25
	-fx-highlight-fill: rgba(0, 180, 255, 0.4);
26
}
27
/* Scroll */
28
.scroll-bar {
29
	-fx-background-color: rgb(61,61,62);
30
}
31
.scroll-bar .thumb {
32
	-fx-background-color: rgb(91,91,92);
33
	-fx-background-radius: 0;
34
}
35
.scroll-bar .thumb:hover,
36
.scroll-bar .thumb:pressed {
37
	-fx-background-color: rgb(141,141,142);
38
}
39
.scroll-bar .increment-button .increment-arrow,
40
.scroll-bar .decrement-button .decrement-arrow {
41
	-fx-background-color: rgb(200,200,200);
42
}
43
.corner {
44
    -fx-background-color: rgb(61,61,62);
45
}
46
/* Menu */
47
.menu-bar {
48
	-fx-background-color: rgb(45, 45, 48);
49
}
50
.menu {
51
	-fx-padding: 6 14 6 14;
52
	-fx-background-insets: -1;
53
}
54
.menu-item {
55
	-fx-padding: 5 11 5 11;
56
	-fx-background-insets: -1;
57
}
58
.menu:hover {
59
	-fx-background-color: rgb(61, 61, 62);
60
}
61
.context-menu,
62
.menu:showing {
63
	-fx-background-color: rgb(27, 27, 28);
64
	-fx-border-insets: -1;
65
	-fx-border-width: 1;
66
	-fx-border-color: black;
67
}
68
.context-menu {
69
	-fx-min-width: 80px;
70
	-fx-background-insets: -1;
71
	-fx-border-insets: -1;
72
	-fx-border-width: 1;
73
	-fx-border-color: black;
74
}
75
.context-menu .menu-item:focused {
76
	-fx-background-color: rgb(61, 61, 62);
77
}
78
.context-menu-header {
79
	/* TODO: Find a way to disable hover coloring on the menu header */
80
	-fx-opacity: 1.0;
81
	-fx-background-color: rgb(24, 50, 95);
82
}
83
.context-menu-header .label {
84
    -fx-opacity: 1.0;
85
}
86
87
/* Tabs */
88
.tab-pane {
89
	-fx-tab-min-width: 100px;
90
}
91
.tab-pane *.tab-header-background {
92
	-fx-background-color: rgb(29, 29, 31);
93
	-fx-border-width: 0 0 1 0;
94
	-fx-border-color: black;
95
}
96
.headers-region {
97
	-fx-background-color: rgb(75, 75, 76);
98
}
99
.tab {
100
	-fx-background-color: rgb(36,36,37);
101
	-fx-background-insets: 2 -1 -1 -1;
102
	-fx-background-radius: 0;
103
	-fx-padding: 2 2 1 2;
104
	-fx-border-insets: 0;
105
	-fx-border-width: 1 1 1 1;
106
	-fx-border-color: black;
107
}
108
.tab:selected {
109
	-fx-background-color: rgb(45, 45, 46);
110
	-fx-background-insets: 2 -1 -1 -1;
111
	-fx-padding: 2;
112
	-fx-border-insets: 0;
113
	-fx-border-width: 1 1 0 1;
114
	-fx-border-color: black;
115
}
116
.tab:selected .focus-indicator {
117
	-fx-border-color: transparent;
118
}
119
/* Table */
120
.table-view {
121
	-fx-selection-bar: rgb(50, 71, 77);
122
	-fx-selection-bar-non-focused: rgb(46, 56, 59);
123
	-fx-background-color: rgb(36,36,37);
124
	-fx-background-insets: 2 -1 -1 -1;
125
	-fx-background-radius: 0;
126
	-fx-padding: -1;
127
	-fx-border-width: 0 1 1 1;
128
	-fx-border-color: rgb(22, 22, 23);
129
}
130
.table-view .filler,
131
.table-view .show-hide-columns-button,
132
.column-overlay {
133
	-fx-background-color: transparent;
134
}
135
.column-header-background {
136
	-fx-background-color: rgb(36,36,37);
137
	-fx-background-insets: 2 -1 -1 -1;
138
	-fx-padding: -1;
139
	-fx-border-insets: 0;
140
	-fx-border-width: 0 1 0 1;
141
	-fx-border-color: rgb(22, 22, 23);
142
}
143
.column-header {
144
	-fx-background-color: rgb(45, 45, 46);
145
	-fx-background-insets: -1 -0 -1 0;
146
	-fx-padding: 2;
147
	-fx-border-insets: 1 -1 1 0;
148
	-fx-border-width: 1;
149
	-fx-border-color: rgb(22, 22, 23);
150
}
151
/* Splitpane */
152
.split-pane-divider {
153
	-fx-background-color: black;
154
	-fx-padding: 0;
155
	-fx-background-insets: -5;
156
}
157
/* Tree */
158
.tree-table-view,
159
.tree-view {
160
	-fx-background-color: rgb(29, 29, 31);
161
	-fx-background-insets: 0;
162
	-fx-border-width: 0 1 0 0;
163
	-fx-border-color: black;
164
}
165
.tree-table-cell,
166
.tree-cell {
167
	-fx-background-color: rgb(29, 29, 31);
168
}
169
.tree-cell:selected {
170
	-fx-background-color: rgb(44, 48, 55);
171
}
172
/* Buttons */
173
.box,
174
.button,
175
.combo-box,
176
.slider .thumb {
177
	-fx-background-radius: 0;
178
	-fx-background-color: rgb(63, 63, 70);
179
	-fx-background-insets: 0;
180
	-fx-border-width: 1;
181
	-fx-border-color: rgb(85, 85, 85);
182
}
183
.check-box:hover .box,
184
.button:hover,
185
.combo-box:hover,
186
.slider .thumb:hover {
187
	-fx-background-color: rgb(80, 80, 85);
188
	-fx-border-color: rgb(0, 122, 205);
189
}
190
.check-box:pressed .box,
191
.button:pressed,
192
.combo-box:pressed,
193
.slider .thumb:pressed {
194
	-fx-background-color: rgb(0, 122, 205);
195
	-fx-border-color: rgb(0, 162, 245);
196
}
197
.combo-box:showing {
198
	-fx-background-color: rgb(27, 27, 28);
199
	-fx-border-width: 1 1 0 1;
200
	-fx-border-color: black;
201
}
202
.combo-box .combo-box-popup .list-cell {
203
	-fx-background-color: rgb(27, 27, 28);
204
}
205
.combo-box .combo-box-popup .list-cell:hover {
206
	-fx-background-color: rgb(61, 61, 62);
207
}
208
.combo-box .combo-box-popup .list-view {
209
	-fx-background-color: rgb(27, 27, 28);
210
	-fx-border-width: 0 1 1 1;
211
	-fx-border-color: black;
212
}
213
.hyperlink {
214
	-fx-text-fill: rgb(30, 132, 250);
215
}
216
hyperlink:visited {
217
	-fx-text-fill: rgb(98, 59, 217);
218
}
219
/* slider */
220
.slider .track {
221
	-fx-background-radius: 0;
222
	-fx-background-color: rgb(29, 29, 31);
223
	-fx-background-insets: 0;
224
	-fx-border-width: 1;
225
	-fx-border-color: rgb(65, 65, 65);
226
}
227
.slider .thumb {
228
/*
229
	-fx-background-insets: 3;
230
	-fx-border-insets: 3;
231
	*/
232
	-fx-padding: 5;
233
}
234
.axis-tick-mark {
235
	-fx-stroke: rgb(100, 100, 100);
236
}
237
/* Text */
238
.text-area .content,
239
.text-field {
240
	-fx-background-radius: 0;
241
	-fx-background-color: rgb(63, 63, 70);
242
	-fx-background-insets: 0;
243
	-fx-border-width: 1;
244
	-fx-border-color: rgb(85, 85, 85);
245
}
246
.text-area {
247
	-fx-background-radius: 0;
248
	-fx-background-color: rgb(63, 63, 70);
249
	-fx-background-insets: 0;
250
	-fx-border-width: 1;
251
	-fx-border-color: rgb(85, 85, 85);
252
}
253
.text-area .content {
254
	-fx-border-width: 0;
255
}
256
/* Popup */
257
.tooltip {
258
	-fx-background-radius: 0;
259
	-fx-background-color: rgb(40, 40, 42);
260
	-fx-background-insets: 0;
261
	-fx-border-width: 1;
262
	-fx-border-color: rgb(70, 70, 72);
263
}
264
/* =========================
265
 * ==   Attach Elements   ==
266
 * =========================
267
 */
268
.vm-view {
269
	-fx-border-width: 0 0 0 1;
270
	-fx-border-color: black;
271
}
272
.vm-buttons {
273
	-fx-padding: 1 0 1 0;
274
}
275
.vm-buttons .button {
276
	-fx-min-width: 140px;
277
	-fx-min-height: 48px;
278
}
279
.vm-icon {
280
	-fx-padding: 2 15 2 2;
281
}
2821
283
/* =========================
284
 * ==   History Elements  ==
285
 * =========================
286
 */
287
.hist-view {
288
	-fx-border-width: 0 0 0 1;
289
	-fx-border-color: black;
290
}
291
.hist-buttons {
292
	-fx-padding: 1 0 1 0;
293
}
294
.hist-buttons .button {
295
	-fx-min-width: 140px;
296
	-fx-min-height: 48px;
297
}
298
.hist-icon {
299
	-fx-padding: 2 13 2 2;
300
}
301
/* =========================
302
 * ==    Other Elements   ==
303
 * =========================
304
 */
305
.faint {
306
	-fx-text-fill: rgb(134, 134, 135);
307
}
308
.search-button {
309
	-fx-background-image: url('../icons/find-light.png');
310
}
311
.search-field {
312
	-fx-prompt-text-fill: rgb(134, 134, 135);
313
	-fx-background-image: url('../icons/find-light.png');
314
	-fx-background-color: rgb(39, 39, 41);
315
	-fx-border-width: 1;
316
	-fx-border-insets: 0 0 -1 -1;
317
	-fx-border-color: black;
318
}
319
.resource-selector {
320
	-fx-prompt-text-fill: rgb(134, 134, 135);
321
	-fx-background-color: rgb(39, 39, 41);
322
	-fx-border-color: rgb(39, 39, 41) black black rgb(39, 39, 41);
323
	-fx-border-insets: 0 0 0 -1;
324
}
325
.resource-selector:hover {
326
	-fx-border-width: 1;
327
	-fx-border-insets: 0;
328
	-fx-padding: 0 0 0 -1;
329
}
330
.resource-selector:showing {
331
	-fx-border-color: black;
332
	-fx-border-insets: 0;
333
	-fx-border-width: 1 1 0 1;
334
	-fx-padding: 0 0 1 -1;
335
}
336
  /* Javadoc popup */
337
.drag-popup-wrapper {
338
	-fx-background-radius: 0;
339
	-fx-background-color: rgb(40, 40, 42);
340
	-fx-background-insets: 0;
341
	-fx-border-width: 1;
342
	-fx-border-color: rgb(95, 95, 95)
343
}
344
.drag-popup-wrapper .scroll-pane {
345
	-fx-background-insets: 0;
346
	-fx-border-width: 0;
347
	-fx-padding: 15;
348
}
349
.drag-popup-header {
350
	-fx-padding: 5;
351
	-fx-background-radius: 0;
352
	-fx-background-color: rgb(63, 63, 70);
353
	-fx-background-insets: 0;
354
	-fx-border-width: 0 0 1 0;
355
	-fx-border-color: rgb(95, 95, 95);
356
}
357
.update-header {
358
	-fx-padding: 5;
359
	-fx-background-color: rgb(32, 33, 35);
360
	-fx-border-width: 0 0 1 0;
361
	-fx-border-color: rgb(95, 95, 95);
362
}
363
.update-notes * {
364
	-fx-fill: rgb(220, 220, 220);
365
}
D src/main/resources/styles/dark/sakura-solarized.css
1
/* $color-text: #dedce5; */
2
/* Sakura.css v1.3.1
3
 * ================
4
 * Minimal css theme.
5
 * Project: https://github.com/oxalorg/sakura/
6
 */
7
/* Body */
8
html {
9
  font-size: 62.5%;
10
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; }
11
12
body {
13
  font-size: 1.8rem;
14
  line-height: 1.618;
15
  max-width: 38em;
16
  margin: auto;
17
  color: #839496;
18
  background-color: #002b36;
19
  padding: 13px; }
20
21
@media (max-width: 684px) {
22
  body {
23
    font-size: 1.53rem; } }
24
25
@media (max-width: 382px) {
26
  body {
27
    font-size: 1.35rem; } }
28
29
h1, h2, h3, h4, h5, h6 {
30
  line-height: 1.1;
31
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
32
  font-weight: 700;
33
  margin-top: 3rem;
34
  margin-bottom: 1.5rem;
35
  overflow-wrap: break-word;
36
  word-wrap: break-word;
37
  -ms-word-break: break-all;
38
  word-break: break-word; }
39
40
h1 {
41
  font-size: 2.35em; }
42
43
h2 {
44
  font-size: 2.00em; }
45
46
h3 {
47
  font-size: 1.75em; }
48
49
h4 {
50
  font-size: 1.5em; }
51
52
h5 {
53
  font-size: 1.25em; }
54
55
h6 {
56
  font-size: 1em; }
57
58
p {
59
  margin-top: 0px;
60
  margin-bottom: 2.5rem; }
61
62
small, sub, sup {
63
  font-size: 75%; }
64
65
hr {
66
  border-color: #2aa198; }
67
68
a {
69
  text-decoration: none;
70
  color: #2aa198; }
71
  a:hover {
72
    color: #657b83;
73
    border-bottom: 2px solid #839496; }
74
  a:visited {
75
    color: #1f7972; }
76
77
ul {
78
  padding-left: 1.4em;
79
  margin-top: 0px;
80
  margin-bottom: 2.5rem; }
81
82
li {
83
  margin-bottom: 0.4em; }
84
85
blockquote {
86
  margin-left: 0px;
87
  margin-right: 0px;
88
  padding-left: 1em;
89
  padding-top: 0.8em;
90
  padding-bottom: 0.8em;
91
  padding-right: 0.8em;
92
  border-left: 5px solid #2aa198;
93
  margin-bottom: 2.5rem;
94
  background-color: #073642; }
95
96
blockquote p {
97
  margin-bottom: 0; }
98
99
img, video {
100
  height: auto;
101
  max-width: 100%;
102
  margin-top: 0px;
103
  margin-bottom: 2.5rem; }
104
105
/* Pre and Code */
106
pre {
107
  background-color: #073642;
108
  display: block;
109
  padding: 1em;
110
  overflow-x: auto;
111
  margin-top: 0px;
112
  margin-bottom: 2.5rem; }
113
114
code {
115
  font-size: 0.9em;
116
  padding: 0 0.5em;
117
  background-color: #073642;
118
  white-space: pre-wrap; }
119
120
pre > code {
121
  padding: 0;
122
  background-color: transparent;
123
  white-space: pre; }
124
125
/* Tables */
126
table {
127
  text-align: justify;
128
  width: 100%;
129
  border-collapse: collapse; }
130
131
td, th {
132
  padding: 0.5em;
133
  border-bottom: 1px solid #073642; }
134
135
/* Buttons, forms and input */
136
input, textarea {
137
  border: 1px solid #839496; }
138
  input:focus, textarea:focus {
139
    border: 1px solid #2aa198; }
140
141
textarea {
142
  width: 100%; }
143
144
.button, button, input[type="submit"], input[type="reset"], input[type="button"] {
145
  display: inline-block;
146
  padding: 5px 10px;
147
  text-align: center;
148
  text-decoration: none;
149
  white-space: nowrap;
150
  background-color: #2aa198;
151
  color: #002b36;
152
  border-radius: 1px;
153
  border: 1px solid #2aa198;
154
  cursor: pointer;
155
  box-sizing: border-box; }
156
  .button[disabled], button[disabled], input[type="submit"][disabled], input[type="reset"][disabled], input[type="button"][disabled] {
157
    cursor: default;
158
    opacity: .5; }
159
  .button:focus:enabled, .button:hover:enabled, button:focus:enabled, button:hover:enabled, input[type="submit"]:focus:enabled, input[type="submit"]:hover:enabled, input[type="reset"]:focus:enabled, input[type="reset"]:hover:enabled, input[type="button"]:focus:enabled, input[type="button"]:hover:enabled {
160
    background-color: #657b83;
161
    border-color: #657b83;
162
    color: #002b36;
163
    outline: 0; }
164
165
textarea, select, input {
166
  color: #839496;
167
  padding: 6px 10px;
168
  /* The 6px vertically centers text on FF, ignored by Webkit */
169
  margin-bottom: 10px;
170
  background-color: #073642;
171
  border: 1px solid #073642;
172
  border-radius: 4px;
173
  box-shadow: none;
174
  box-sizing: border-box; }
175
  textarea:focus, select:focus, input:focus {
176
    border: 1px solid #2aa198;
177
    outline: 0; }
178
179
input[type="checkbox"]:focus {
180
  outline: 1px dotted #2aa198; }
181
182
label, legend, fieldset {
183
  display: block;
184
  margin-bottom: .5rem;
185
  font-weight: 600; }
1861
D src/main/resources/styles/dark/toedter.css
1
.root {
2
  -fx-base: rgb(50, 50, 50);
3
  -fx-background: rgb(50, 50, 50);
4
  -fx-control-inner-background:  rgb(50, 50, 50);
5
}
6
 
7
.tab {
8
  -fx-background-color: linear-gradient(to top, -fx-base, derive(-fx-base,30%));
9
}
10
 
11
.menu-bar {
12
  -fx-background-color: linear-gradient(to bottom, -fx-base, derive(-fx-base,30%));
13
}
14
 
15
.tool-bar:horizontal {
16
  -fx-background-color:
17
linear-gradient(to bottom, derive(-fx-base,+50%), derive(-fx-base,-40%), derive(-fx-base,-20%));
18
}
19
 
20
.button {
21
  -fx-background-color: transparent;
22
}
23
 
24
.button:hover {
25
  -fx-background-color: -fx-shadow-highlight-color, -fx-outer-border, -fx-inner-border, -fx-body-color;
26
  -fx-color: -fx-hover-base;
27
}
28
 
29
.table-view {
30
  -fx-table-cell-border-color:derive(-fx-base,+10%);
31
  -fx-table-header-border-color:derive(-fx-base,+20%);
32
}
33
 
34
.split-pane:horizontal > * > .split-pane-divider {
35
  -fx-border-color: transparent -fx-base transparent -fx-base;
36
  -fx-background-color: transparent, derive(-fx-base,20%);
37
  -fx-background-insets: 0, 0 1 0 1;
38
}
39
 
40
.my-gridpane {
41
  -fx-background-color: radial-gradient(radius 100%, derive(-fx-base,20%), derive(-fx-base,-20%));
42
}
43
 
44
.separator-label {
45
  -fx-text-fill: orange;
46
}
47
481
A src/test/java/com/keenwrite/io/FileWatchServiceTest.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import org.junit.jupiter.api.Test;
5
import org.junit.jupiter.api.Timeout;
6
7
import java.io.File;
8
import java.io.IOException;
9
import java.util.concurrent.Semaphore;
10
import java.util.function.Consumer;
11
12
import static java.io.File.createTempFile;
13
import static java.nio.file.Files.write;
14
import static java.nio.file.StandardOpenOption.APPEND;
15
import static java.nio.file.StandardOpenOption.CREATE;
16
import static java.util.concurrent.TimeUnit.SECONDS;
17
import static org.junit.jupiter.api.Assertions.assertEquals;
18
19
/**
20
 * Responsible for testing that the {@link FileWatchService} fires the
21
 * expected {@link FileEvent} when the system raises state changes.
22
 */
23
class FileWatchServiceTest {
24
  /**
25
   * Test that modifying a file produces a {@link FileEvent}.
26
   *
27
   * @throws IOException          Could not create watcher service.
28
   * @throws InterruptedException Could not join on watcher service thread.
29
   */
30
  @Test
31
  @Timeout( value = 5, unit = SECONDS )
32
  void test_SingleFile_Write_Notified() throws
33
    IOException, InterruptedException {
34
    final var text = "arbitrary text to write";
35
    final var file = createTemporaryFile();
36
    final var service = new FileWatchService( file );
37
    final var thread = new Thread( service );
38
    final var semaphor = new Semaphore( 0 );
39
    final var listener = createListener( ( f ) -> {
40
      semaphor.release();
41
      assertEquals( file, f );
42
    } );
43
44
    thread.start();
45
    service.addListener( listener );
46
    write( file.toPath(), text.getBytes(), CREATE, APPEND );
47
    semaphor.acquire();
48
    service.stop();
49
    thread.join();
50
  }
51
52
  private FileModifiedListener createListener( final Consumer<File> action ) {
53
    return fileEvent -> action.accept( fileEvent.getFile() );
54
  }
55
56
  private File createTemporaryFile() throws IOException {
57
    final var prefix = getClass().getPackageName();
58
    final var file = createTempFile( prefix, null, null );
59
    file.deleteOnExit();
60
    return file;
61
  }
62
}
163
A src/test/java/com/keenwrite/sigils/RSigilOperatorTest.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.sigils;
3
4
import javafx.beans.property.SimpleStringProperty;
5
import javafx.beans.property.StringProperty;
6
import org.junit.jupiter.api.Test;
7
8
import static org.junit.jupiter.api.Assertions.assertEquals;
9
10
/**
11
 * Responsible for simulating R variable injection.
12
 */
13
class RSigilOperatorTest {
14
15
  private final SigilOperator mOperator = createRSigilOperator();
16
17
  /**
18
   * Test that a key name becomes an R variable.
19
   */
20
  @Test
21
  void test_Entoken_KeyName_Tokenized() {
22
    final var expected = "v$a$b$c$d";
23
    final var actual = mOperator.entoken( "{{a.b.c.d}}" );
24
    assertEquals( expected, actual );
25
  }
26
27
  /**
28
   * Test that a key name becomes a viable R expression.
29
   */
30
  @Test
31
  void test_Apply_KeyName_Expression() {
32
    final var expected = "`r#x(v$a$b$c$d)`";
33
    final var actual = mOperator.apply( "v$a$b$c$d" );
34
    assertEquals( expected, actual );
35
  }
36
37
  private StringProperty createToken( final String token ) {
38
    return new SimpleStringProperty( token );
39
  }
40
41
  private Tokens createRTokens() {
42
    return createTokens( "x(", ")" );
43
  }
44
45
  private Tokens createYamlTokens() {
46
    return createTokens( "{{", "}}" );
47
  }
48
49
  private Tokens createTokens( final String began, final String ended ) {
50
    return new Tokens( createToken( began ), createToken( ended ) );
51
  }
52
53
  private YamlSigilOperator createYamlSigilOperator() {
54
    return new YamlSigilOperator( createYamlTokens() );
55
  }
56
57
  private RSigilOperator createRSigilOperator() {
58
    return new RSigilOperator( createRTokens(), createYamlSigilOperator() );
59
  }
60
}
161