Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M src/main/java/com/keenwrite/editors/common/ScrollEventHandler.java
117117
    invokeLater( () -> {
118118
      if( isEnabled() ) {
119
        // e is for editor pane
119
        // e prefix is for editor pane.
120120
        final var eScrollPane = getEditorScrollPane();
121121
        final var eScrollY =
122122
          eScrollPane.estimatedScrollYProperty().getValue().intValue();
123123
        final var eHeight = (int)
124124
          (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
125125
            - eScrollPane.getHeight());
126126
        final var eRatio = eHeight > 0
127127
          ? min( max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
128128
129
        // p is for preview pane
129
        // p prefix is for preview pane.
130130
        final var pScrollBar = getPreviewScrollBar();
131131
        final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
183183
184184
    try {
185
      final var root = getTreeView().getRoot();
186
      final var problem = isTreeWellFormed();
185
      result.append( mTreeTransformer.transform( getTreeView().getRoot() ) );
187186
188
      problem.ifPresentOrElse(
189
        node -> clue( "yaml.error.tree.form", node ),
190
        () -> result.append( mTreeTransformer.transform( root ) )
191
      );
187
      final var problem = isTreeWellFormed();
188
      problem.ifPresent( node -> clue( "yaml.error.tree.form", node ) );
192189
    } catch( final Exception ex ) {
193190
      // Catch errors while checking for a well-formed tree (e.g., stack smash).
M src/main/java/com/keenwrite/io/SysFile.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
12
package com.keenwrite.io;
23
M src/main/java/com/keenwrite/preferences/AppKeys.java
8686
  public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" );
8787
  public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" );
88
  public static final Key KEY_TYPESET_CONTEXT_CHAPTERS = key( KEY_TYPESET_CONTEXT, "chapters" );
8889
  public static final Key KEY_TYPESET_TYPOGRAPHY = key( KEY_TYPESET, "typography" );
8990
  public static final Key KEY_TYPESET_TYPOGRAPHY_QUOTES = key( KEY_TYPESET_TYPOGRAPHY, "quotes" );
M src/main/java/com/keenwrite/preferences/Workspace.java
110110
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
111111
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
112
    entry( KEY_TYPESET_CONTEXT_CHAPTERS, asStringProperty( "" ) ),
112113
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
113114
    //@formatter:on
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
33
44
import com.keenwrite.processors.markdown.MarkdownProcessor;
5
import com.keenwrite.processors.r.RBootstrapController;
6
import com.keenwrite.processors.r.RInlineEvaluator;
7
import com.keenwrite.processors.r.RVariableProcessor;
85
96
import static com.keenwrite.io.FileType.RMARKDOWN;
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
134134
135135
    metadata.forEach(
136
      ( key, value ) -> result.put( key, map.interpolate( value ) )
136
      ( key, value ) -> {
137
        final var interpolated = map.interpolate( value );
138
139
        if( !interpolated.isEmpty() ) {
140
          result.put( key, interpolated );
141
        }
142
      }
137143
    );
138144
    result.put( "count", wordCount( doc ) );
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
1212
import com.keenwrite.events.CaretMovedEvent;
1313
import com.keenwrite.events.ExportFailedEvent;
14
import com.keenwrite.preferences.PreferencesController;
15
import com.keenwrite.preferences.Workspace;
16
import com.keenwrite.processors.markdown.MarkdownProcessor;
17
import com.keenwrite.search.SearchModel;
18
import com.keenwrite.typesetting.Typesetter;
19
import com.keenwrite.ui.controls.SearchBar;
20
import com.keenwrite.ui.dialogs.ImageDialog;
21
import com.keenwrite.ui.dialogs.LinkDialog;
22
import com.keenwrite.ui.dialogs.ThemePicker;
23
import com.keenwrite.ui.explorer.FilePicker;
24
import com.keenwrite.ui.explorer.FilePickerFactory;
25
import com.keenwrite.ui.logging.LogView;
26
import com.keenwrite.util.AlphanumComparator;
27
import com.vladsch.flexmark.ast.Link;
28
import javafx.concurrent.Task;
29
import javafx.scene.control.Alert;
30
import javafx.scene.control.Dialog;
31
import javafx.stage.Window;
32
import javafx.stage.WindowEvent;
33
34
import java.io.File;
35
import java.io.IOException;
36
import java.nio.file.Path;
37
import java.util.ArrayList;
38
import java.util.List;
39
import java.util.Optional;
40
import java.util.concurrent.ExecutorService;
41
42
import static com.keenwrite.Bootstrap.*;
43
import static com.keenwrite.ExportFormat.*;
44
import static com.keenwrite.Messages.get;
45
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
46
import static com.keenwrite.events.StatusEvent.clue;
47
import static com.keenwrite.preferences.AppKeys.*;
48
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
49
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
50
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
51
import static com.keenwrite.util.FileWalker.walk;
52
import static java.nio.file.Files.readString;
53
import static java.nio.file.Files.writeString;
54
import static java.util.concurrent.Executors.newFixedThreadPool;
55
import static javafx.application.Platform.runLater;
56
import static javafx.event.Event.fireEvent;
57
import static javafx.scene.control.Alert.AlertType.INFORMATION;
58
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
59
import static org.apache.commons.io.FilenameUtils.getExtension;
60
61
/**
62
 * Responsible for abstracting how functionality is mapped to the application.
63
 * This allows users to customize accelerator keys and will provide pluggable
64
 * functionality so that different text markup languages can change documents
65
 * using their respective syntax.
66
 */
67
public final class GuiCommands {
68
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
69
70
  private static final String STYLE_SEARCH = "search";
71
72
  /**
73
   * Sci-fi genres, which are can be longer than other genres, typically fall
74
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
75
   * memory when concatenating files together when exporting novels.
76
   */
77
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
78
79
  /**
80
   * When an action is executed, this is one of the recipients.
81
   */
82
  private final MainPane mMainPane;
83
84
  private final MainScene mMainScene;
85
86
  private final LogView mLogView;
87
88
  /**
89
   * Tracks finding text in the active document.
90
   */
91
  private final SearchModel mSearchModel;
92
93
  public GuiCommands( final MainScene scene, final MainPane pane ) {
94
    mMainScene = scene;
95
    mMainPane = pane;
96
    mLogView = new LogView();
97
    mSearchModel = new SearchModel();
98
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
99
      final var editor = getActiveTextEditor();
100
101
      // Clear highlighted areas before highlighting a new region.
102
      if( o != null ) {
103
        editor.unstylize( STYLE_SEARCH );
104
      }
105
106
      if( n != null ) {
107
        editor.moveTo( n.getStart() );
108
        editor.stylize( n, STYLE_SEARCH );
109
      }
110
    } );
111
112
    // When the active text editor changes ...
113
    mMainPane.textEditorProperty().addListener(
114
      ( c, o, n ) -> {
115
        // ... update the haystack.
116
        mSearchModel.search( getActiveTextEditor().getText() );
117
118
        // ... update the status bar with the current caret position.
119
        if( n != null ) {
120
          CaretMovedEvent.fire( n.getCaret() );
121
        }
122
      }
123
    );
124
  }
125
126
  public void file_new() {
127
    getMainPane().newTextEditor();
128
  }
129
130
  public void file_open() {
131
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
132
  }
133
134
  public void file_close() {
135
    getMainPane().close();
136
  }
137
138
  public void file_close_all() {
139
    getMainPane().closeAll();
140
  }
141
142
  public void file_save() {
143
    getMainPane().save();
144
  }
145
146
  public void file_save_as() {
147
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
148
  }
149
150
  public void file_save_all() {
151
    getMainPane().saveAll();
152
  }
153
154
  /**
155
   * Converts the actively edited file in the given file format.
156
   *
157
   * @param format The destination file format.
158
   */
159
  private void file_export( final ExportFormat format ) {
160
    file_export( format, false );
161
  }
162
163
  /**
164
   * Converts one or more files into the given file format. If {@code dir}
165
   * is set to true, this will first append all files in the same directory
166
   * as the actively edited file.
167
   *
168
   * @param format The destination file format.
169
   * @param dir    Export all files in the actively edited file's directory.
170
   */
171
  private void file_export( final ExportFormat format, final boolean dir ) {
172
    final var main = getMainPane();
173
    final var editor = main.getTextEditor();
174
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
175
    final var filename = format.toExportFilename( editor.getPath() );
176
    final var selection = pickFile(
177
      Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
178
        ? filename
179
        : exported.get(), FILE_EXPORT
180
    );
181
182
    selection.ifPresent( ( files ) -> {
183
      editor.save();
184
185
      final var file = files.get( 0 );
186
      final var path = file.toPath();
187
      final var document = dir ? append( editor ) : editor.getText();
188
      final var context = main.createProcessorContext( path, format );
189
190
      final var task = new Task<Path>() {
191
        @Override
192
        protected Path call() throws Exception {
193
          final var chain = createProcessors( context );
194
          final var export = chain.apply( document );
195
196
          // Processors can export binary files. In such cases, processors
197
          // return null to prevent further processing.
198
          return export == null ? null : writeString( path, export );
199
        }
200
      };
201
202
      task.setOnSucceeded(
203
        e -> {
204
          // Remember the exported file name for next time.
205
          exported.setValue( file );
206
207
          final var result = task.getValue();
208
209
          // Binary formats must notify users of success independently.
210
          if( result != null ) {
211
            clue( "Main.status.export.success", result );
212
          }
213
        }
214
      );
215
216
      task.setOnFailed( e -> {
217
        final var ex = task.getException();
218
        clue( ex );
219
220
        if( ex instanceof TypeNotPresentException ) {
221
          fireExportFailedEvent();
222
        }
223
      } );
224
225
      sExecutor.execute( task );
226
    } );
227
  }
228
229
  /**
230
   * @param dir {@code true} means to export all files in the active file
231
   *            editor's directory; {@code false} means to export only the
232
   *            actively edited file.
233
   */
234
  private void file_export_pdf( final boolean dir ) {
235
    final var workspace = getWorkspace();
236
    final var themes = workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
237
    final var theme = workspace.stringProperty(
238
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
239
240
    if( Typesetter.canRun() ) {
241
      // If the typesetter is installed, allow the user to select a theme. If
242
      // the themes aren't installed, a status message will appear.
243
      if( ThemePicker.choose( themes, theme ) ) {
244
        file_export( APPLICATION_PDF, dir );
245
      }
246
    }
247
    else {
248
      fireExportFailedEvent();
249
    }
250
  }
251
252
  public void file_export_pdf() {
253
    file_export_pdf( false );
254
  }
255
256
  public void file_export_pdf_dir() {
257
    file_export_pdf( true );
258
  }
259
260
  public void file_export_html_svg() {
261
    file_export( HTML_TEX_SVG );
262
  }
263
264
  public void file_export_html_tex() {
265
    file_export( HTML_TEX_DELIMITED );
266
  }
267
268
  public void file_export_xhtml_tex() {
269
    file_export( XHTML_TEX );
270
  }
271
272
  private void fireExportFailedEvent() {
273
    runLater( ExportFailedEvent::fire );
274
  }
275
276
  public void file_exit() {
277
    final var window = getWindow();
278
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
279
  }
280
281
  public void edit_undo() {
282
    getActiveTextEditor().undo();
283
  }
284
285
  public void edit_redo() {
286
    getActiveTextEditor().redo();
287
  }
288
289
  public void edit_cut() {
290
    getActiveTextEditor().cut();
291
  }
292
293
  public void edit_copy() {
294
    getActiveTextEditor().copy();
295
  }
296
297
  public void edit_paste() {
298
    getActiveTextEditor().paste();
299
  }
300
301
  public void edit_select_all() {
302
    getActiveTextEditor().selectAll();
303
  }
304
305
  public void edit_find() {
306
    final var nodes = getMainScene().getStatusBar().getLeftItems();
307
308
    if( nodes.isEmpty() ) {
309
      final var searchBar = new SearchBar();
310
311
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
312
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
313
314
      searchBar.setOnCancelAction( ( event ) -> {
315
        final var editor = getActiveTextEditor();
316
        nodes.remove( searchBar );
317
        editor.unstylize( STYLE_SEARCH );
318
        editor.getNode().requestFocus();
319
      } );
320
321
      searchBar.addInputListener( ( c, o, n ) -> {
322
        if( n != null && !n.isEmpty() ) {
323
          mSearchModel.search( n, getActiveTextEditor().getText() );
324
        }
325
      } );
326
327
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
328
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
329
330
      nodes.add( searchBar );
331
      searchBar.requestFocus();
332
    }
333
    else {
334
      nodes.clear();
335
    }
336
  }
337
338
  public void edit_find_next() {
339
    mSearchModel.advance();
340
  }
341
342
  public void edit_find_prev() {
343
    mSearchModel.retreat();
344
  }
345
346
  public void edit_preferences() {
347
    try {
348
      new PreferencesController( getWorkspace() ).show();
349
    } catch( final Exception ex ) {
350
      clue( ex );
351
    }
352
  }
353
354
  public void format_bold() {
355
    getActiveTextEditor().bold();
356
  }
357
358
  public void format_italic() {
359
    getActiveTextEditor().italic();
360
  }
361
362
  public void format_monospace() {
363
    getActiveTextEditor().monospace();
364
  }
365
366
  public void format_superscript() {
367
    getActiveTextEditor().superscript();
368
  }
369
370
  public void format_subscript() {
371
    getActiveTextEditor().subscript();
372
  }
373
374
  public void format_strikethrough() {
375
    getActiveTextEditor().strikethrough();
376
  }
377
378
  public void insert_blockquote() {
379
    getActiveTextEditor().blockquote();
380
  }
381
382
  public void insert_code() {
383
    getActiveTextEditor().code();
384
  }
385
386
  public void insert_fenced_code_block() {
387
    getActiveTextEditor().fencedCodeBlock();
388
  }
389
390
  public void insert_link() {
391
    insertObject( createLinkDialog() );
392
  }
393
394
  public void insert_image() {
395
    insertObject( createImageDialog() );
396
  }
397
398
  private void insertObject( final Dialog<String> dialog ) {
399
    final var textArea = getActiveTextEditor().getTextArea();
400
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
401
  }
402
403
  private Dialog<String> createLinkDialog() {
404
    return new LinkDialog( getWindow(), createHyperlinkModel() );
405
  }
406
407
  private Dialog<String> createImageDialog() {
408
    final var path = getActiveTextEditor().getPath();
409
    final var parentDir = path.getParent();
410
    return new ImageDialog( getWindow(), parentDir );
411
  }
412
413
  /**
414
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
415
   * the Markdown AST.
416
   *
417
   * @return An instance containing the link URL and display text.
418
   */
419
  private HyperlinkModel createHyperlinkModel() {
420
    final var context = getMainPane().createProcessorContext();
421
    final var editor = getActiveTextEditor();
422
    final var textArea = editor.getTextArea();
423
    final var selectedText = textArea.getSelectedText();
424
425
    // Convert current paragraph to Markdown nodes.
426
    final var mp = MarkdownProcessor.create( context );
427
    final var p = textArea.getCurrentParagraph();
428
    final var paragraph = textArea.getText( p );
429
    final var node = mp.toNode( paragraph );
430
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
431
    final var link = visitor.process( node );
432
433
    if( link != null ) {
434
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
435
    }
436
437
    return createHyperlinkModel( link, selectedText );
438
  }
439
440
  private HyperlinkModel createHyperlinkModel(
441
    final Link link, final String selection ) {
442
443
    return link == null
444
      ? new HyperlinkModel( selection, "https://localhost" )
445
      : new HyperlinkModel( link );
446
  }
447
448
  public void insert_heading_1() {
449
    insert_heading( 1 );
450
  }
451
452
  public void insert_heading_2() {
453
    insert_heading( 2 );
454
  }
455
456
  public void insert_heading_3() {
457
    insert_heading( 3 );
458
  }
459
460
  private void insert_heading( final int level ) {
461
    getActiveTextEditor().heading( level );
462
  }
463
464
  public void insert_unordered_list() {
465
    getActiveTextEditor().unorderedList();
466
  }
467
468
  public void insert_ordered_list() {
469
    getActiveTextEditor().orderedList();
470
  }
471
472
  public void insert_horizontal_rule() {
473
    getActiveTextEditor().horizontalRule();
474
  }
475
476
  public void definition_create() {
477
    getActiveTextDefinition().createDefinition();
478
  }
479
480
  public void definition_rename() {
481
    getActiveTextDefinition().renameDefinition();
482
  }
483
484
  public void definition_delete() {
485
    getActiveTextDefinition().deleteDefinitions();
486
  }
487
488
  public void definition_autoinsert() {
489
    getMainPane().autoinsert();
490
  }
491
492
  public void view_refresh() {
493
    getMainPane().viewRefresh();
494
  }
495
496
  public void view_preview() {
497
    getMainPane().viewPreview();
498
  }
499
500
  public void view_outline() {
501
    getMainPane().viewOutline();
502
  }
503
504
  public void view_files() {getMainPane().viewFiles();}
505
506
  public void view_statistics() {
507
    getMainPane().viewStatistics();
508
  }
509
510
  public void view_menubar() {
511
    getMainScene().toggleMenuBar();
512
  }
513
514
  public void view_toolbar() {
515
    getMainScene().toggleToolBar();
516
  }
517
518
  public void view_statusbar() {
519
    getMainScene().toggleStatusBar();
520
  }
521
522
  public void view_log() {
523
    mLogView.view();
524
  }
525
526
  public void help_about() {
527
    final var alert = new Alert( INFORMATION );
528
    final var prefix = "Dialog.about.";
529
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
530
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
531
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
532
    alert.setGraphic( ICON_DIALOG_NODE );
533
    alert.initOwner( getWindow() );
534
    alert.showAndWait();
535
  }
536
537
  /**
538
   * Concatenates all the files in the same directory as the given file into
539
   * a string. The extension is determined by the given file name pattern; the
540
   * order files are concatenated is based on their numeric sort order (this
541
   * avoids lexicographic sorting).
542
   * <p>
543
   * If the parent path to the file being edited in the text editor cannot
544
   * be found then this will return the editor's text, without iterating through
545
   * the parent directory. (Should never happen, but who knows?)
546
   * </p>
547
   * <p>
548
   * New lines are automatically appended to separate each file.
549
   * </p>
550
   *
551
   * @param editor The text editor containing
552
   * @return All files in the same directory as the file being edited
553
   * concatenated into a single string.
554
   */
555
  private String append( final TextEditor editor ) {
556
    final var pattern = editor.getPath();
557
    final var parent = pattern.getParent();
558
559
    // Short-circuit because nothing else can be done.
560
    if( parent == null ) {
561
      clue( "Main.status.export.concat.parent", pattern );
562
      return editor.getText();
563
    }
564
565
    final var filename = pattern.getFileName().toString();
566
    final var extension = getExtension( filename );
567
568
    if( extension.isBlank() ) {
569
      clue( "Main.status.export.concat.extension", filename );
570
      return editor.getText();
571
    }
572
573
    try {
574
      final var glob = "**/*." + extension;
575
      final ArrayList<Path> files = new ArrayList<>();
576
      walk( parent, glob, files::add );
577
      files.sort( new AlphanumComparator<>() );
578
579
      final var text = new StringBuilder( DOCUMENT_LENGTH );
580
581
      files.forEach( file -> {
582
        try {
583
          clue( "Main.status.export.concat", file );
584
          text.append( readString( file ) );
585
        } catch( final IOException ex ) {
586
          clue( "Main.status.export.concat.io", file );
587
        }
588
      } );
589
590
      return text.toString();
591
    } catch( final Throwable t ) {
592
      clue( t );
593
      return editor.getText();
594
    }
595
  }
596
597
  private Optional<List<File>> pickFiles( final SelectionType type ) {
598
    return createPicker( type ).choose();
599
  }
600
601
  @SuppressWarnings( "SameParameterValue" )
602
  private Optional<List<File>> pickFile(
603
    final File filename, final SelectionType type ) {
604
    final var picker = createPicker( type );
605
    picker.setInitialFilename( filename );
606
    return picker.choose();
607
  }
608
609
  private FilePicker createPicker( final SelectionType type ) {
610
    final var factory = new FilePickerFactory( getWorkspace() );
611
    return factory.createModal( getWindow(), type );
612
  }
613
614
  private TextEditor getActiveTextEditor() {
615
    return getMainPane().getTextEditor();
616
  }
617
618
  private TextDefinition getActiveTextDefinition() {
619
    return getMainPane().getTextDefinition();
620
  }
621
622
  private MainScene getMainScene() {
623
    return mMainScene;
624
  }
625
626
  private MainPane getMainPane() {
627
    return mMainPane;
628
  }
629
630
  private Workspace getWorkspace() {
631
    return mMainPane.getWorkspace();
14
import com.keenwrite.preferences.Key;
15
import com.keenwrite.preferences.PreferencesController;
16
import com.keenwrite.preferences.Workspace;
17
import com.keenwrite.processors.markdown.MarkdownProcessor;
18
import com.keenwrite.search.SearchModel;
19
import com.keenwrite.typesetting.Typesetter;
20
import com.keenwrite.ui.controls.SearchBar;
21
import com.keenwrite.ui.dialogs.ExportDialog;
22
import com.keenwrite.ui.dialogs.ExportSettings;
23
import com.keenwrite.ui.dialogs.ImageDialog;
24
import com.keenwrite.ui.dialogs.LinkDialog;
25
import com.keenwrite.ui.explorer.FilePicker;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.logging.LogView;
28
import com.keenwrite.util.AlphanumComparator;
29
import com.keenwrite.util.RangeValidator;
30
import com.vladsch.flexmark.ast.Link;
31
import javafx.concurrent.Task;
32
import javafx.scene.control.Alert;
33
import javafx.scene.control.Dialog;
34
import javafx.stage.Window;
35
import javafx.stage.WindowEvent;
36
37
import java.io.File;
38
import java.io.IOException;
39
import java.nio.file.Path;
40
import java.util.ArrayList;
41
import java.util.List;
42
import java.util.Optional;
43
import java.util.concurrent.ExecutorService;
44
import java.util.concurrent.atomic.AtomicInteger;
45
46
import static com.keenwrite.Bootstrap.*;
47
import static com.keenwrite.ExportFormat.*;
48
import static com.keenwrite.Messages.get;
49
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
50
import static com.keenwrite.events.StatusEvent.clue;
51
import static com.keenwrite.preferences.AppKeys.*;
52
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
53
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
55
import static com.keenwrite.util.FileWalker.walk;
56
import static java.nio.file.Files.readString;
57
import static java.nio.file.Files.writeString;
58
import static java.util.concurrent.Executors.newFixedThreadPool;
59
import static javafx.application.Platform.runLater;
60
import static javafx.event.Event.fireEvent;
61
import static javafx.scene.control.Alert.AlertType.INFORMATION;
62
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
63
import static org.apache.commons.io.FilenameUtils.getExtension;
64
65
/**
66
 * Responsible for abstracting how functionality is mapped to the application.
67
 * This allows users to customize accelerator keys and will provide pluggable
68
 * functionality so that different text markup languages can change documents
69
 * using their respective syntax.
70
 */
71
public final class GuiCommands {
72
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
73
74
  private static final String STYLE_SEARCH = "search";
75
76
  /**
77
   * Sci-fi genres, which are can be longer than other genres, typically fall
78
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
79
   * memory when concatenating files together when exporting novels.
80
   */
81
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
82
83
  /**
84
   * When an action is executed, this is one of the recipients.
85
   */
86
  private final MainPane mMainPane;
87
88
  private final MainScene mMainScene;
89
90
  private final LogView mLogView;
91
92
  /**
93
   * Tracks finding text in the active document.
94
   */
95
  private final SearchModel mSearchModel;
96
97
  public GuiCommands( final MainScene scene, final MainPane pane ) {
98
    mMainScene = scene;
99
    mMainPane = pane;
100
    mLogView = new LogView();
101
    mSearchModel = new SearchModel();
102
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
103
      final var editor = getActiveTextEditor();
104
105
      // Clear highlighted areas before highlighting a new region.
106
      if( o != null ) {
107
        editor.unstylize( STYLE_SEARCH );
108
      }
109
110
      if( n != null ) {
111
        editor.moveTo( n.getStart() );
112
        editor.stylize( n, STYLE_SEARCH );
113
      }
114
    } );
115
116
    // When the active text editor changes ...
117
    mMainPane.textEditorProperty().addListener(
118
      ( c, o, n ) -> {
119
        // ... update the haystack.
120
        mSearchModel.search( getActiveTextEditor().getText() );
121
122
        // ... update the status bar with the current caret position.
123
        if( n != null ) {
124
          CaretMovedEvent.fire( n.getCaret() );
125
        }
126
      }
127
    );
128
  }
129
130
  public void file_new() {
131
    getMainPane().newTextEditor();
132
  }
133
134
  public void file_open() {
135
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
136
  }
137
138
  public void file_close() {
139
    getMainPane().close();
140
  }
141
142
  public void file_close_all() {
143
    getMainPane().closeAll();
144
  }
145
146
  public void file_save() {
147
    getMainPane().save();
148
  }
149
150
  public void file_save_as() {
151
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
152
  }
153
154
  public void file_save_all() {
155
    getMainPane().saveAll();
156
  }
157
158
  /**
159
   * Converts the actively edited file in the given file format.
160
   *
161
   * @param format The destination file format.
162
   */
163
  private void file_export( final ExportFormat format ) {
164
    file_export( format, false );
165
  }
166
167
  /**
168
   * Converts one or more files into the given file format. If {@code dir}
169
   * is set to true, this will first append all files in the same directory
170
   * as the actively edited file.
171
   *
172
   * @param format The destination file format.
173
   * @param dir    Export all files in the actively edited file's directory.
174
   */
175
  private void file_export( final ExportFormat format, final boolean dir ) {
176
    final var main = getMainPane();
177
    final var editor = main.getTextEditor();
178
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
179
    final var filename = format.toExportFilename( editor.getPath() );
180
    final var selection = pickFile(
181
      Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
182
        ? filename
183
        : exported.get(), FILE_EXPORT
184
    );
185
186
    selection.ifPresent( ( files ) -> {
187
      editor.save();
188
189
      final var file = files.get( 0 );
190
      final var path = file.toPath();
191
      final var document = dir ? append( editor ) : editor.getText();
192
      final var context = main.createProcessorContext( path, format );
193
194
      final var task = new Task<Path>() {
195
        @Override
196
        protected Path call() throws Exception {
197
          final var chain = createProcessors( context );
198
          final var export = chain.apply( document );
199
200
          // Processors can export binary files. In such cases, processors
201
          // return null to prevent further processing.
202
          return export == null ? null : writeString( path, export );
203
        }
204
      };
205
206
      task.setOnSucceeded(
207
        e -> {
208
          // Remember the exported file name for next time.
209
          exported.setValue( file );
210
211
          final var result = task.getValue();
212
213
          // Binary formats must notify users of success independently.
214
          if( result != null ) {
215
            clue( "Main.status.export.success", result );
216
          }
217
        }
218
      );
219
220
      task.setOnFailed( e -> {
221
        final var ex = task.getException();
222
        clue( ex );
223
224
        if( ex instanceof TypeNotPresentException ) {
225
          fireExportFailedEvent();
226
        }
227
      } );
228
229
      sExecutor.execute( task );
230
    } );
231
  }
232
233
  /**
234
   * @param dir {@code true} means to export all files in the active file
235
   *            editor's directory; {@code false} means to export only the
236
   *            actively edited file.
237
   */
238
  private void file_export_pdf( final boolean dir ) {
239
    final var workspace = getWorkspace();
240
    final var themes = workspace.getFile(
241
      KEY_TYPESET_CONTEXT_THEMES_PATH
242
    );
243
    final var theme = workspace.stringProperty(
244
      KEY_TYPESET_CONTEXT_THEME_SELECTION
245
    );
246
    final var chapters = workspace.stringProperty(
247
      KEY_TYPESET_CONTEXT_CHAPTERS
248
    );
249
    final var settings = ExportSettings
250
      .builder()
251
      .with( ExportSettings.Mutator::setTheme, theme )
252
      .with( ExportSettings.Mutator::setChapters, chapters )
253
      .build();
254
255
    if( Typesetter.canRun() ) {
256
      // If the typesetter is installed, allow the user to select a theme. If
257
      // the themes aren't installed, a status message will appear.
258
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
259
        file_export( APPLICATION_PDF, dir );
260
      }
261
    }
262
    else {
263
      fireExportFailedEvent();
264
    }
265
  }
266
267
  public void file_export_pdf() {
268
    file_export_pdf( false );
269
  }
270
271
  public void file_export_pdf_dir() {
272
    file_export_pdf( true );
273
  }
274
275
  public void file_export_html_svg() {
276
    file_export( HTML_TEX_SVG );
277
  }
278
279
  public void file_export_html_tex() {
280
    file_export( HTML_TEX_DELIMITED );
281
  }
282
283
  public void file_export_xhtml_tex() {
284
    file_export( XHTML_TEX );
285
  }
286
287
  private void fireExportFailedEvent() {
288
    runLater( ExportFailedEvent::fire );
289
  }
290
291
  public void file_exit() {
292
    final var window = getWindow();
293
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
294
  }
295
296
  public void edit_undo() {
297
    getActiveTextEditor().undo();
298
  }
299
300
  public void edit_redo() {
301
    getActiveTextEditor().redo();
302
  }
303
304
  public void edit_cut() {
305
    getActiveTextEditor().cut();
306
  }
307
308
  public void edit_copy() {
309
    getActiveTextEditor().copy();
310
  }
311
312
  public void edit_paste() {
313
    getActiveTextEditor().paste();
314
  }
315
316
  public void edit_select_all() {
317
    getActiveTextEditor().selectAll();
318
  }
319
320
  public void edit_find() {
321
    final var nodes = getMainScene().getStatusBar().getLeftItems();
322
323
    if( nodes.isEmpty() ) {
324
      final var searchBar = new SearchBar();
325
326
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
327
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
328
329
      searchBar.setOnCancelAction( ( event ) -> {
330
        final var editor = getActiveTextEditor();
331
        nodes.remove( searchBar );
332
        editor.unstylize( STYLE_SEARCH );
333
        editor.getNode().requestFocus();
334
      } );
335
336
      searchBar.addInputListener( ( c, o, n ) -> {
337
        if( n != null && !n.isEmpty() ) {
338
          mSearchModel.search( n, getActiveTextEditor().getText() );
339
        }
340
      } );
341
342
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
343
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
344
345
      nodes.add( searchBar );
346
      searchBar.requestFocus();
347
    }
348
    else {
349
      nodes.clear();
350
    }
351
  }
352
353
  public void edit_find_next() {
354
    mSearchModel.advance();
355
  }
356
357
  public void edit_find_prev() {
358
    mSearchModel.retreat();
359
  }
360
361
  public void edit_preferences() {
362
    try {
363
      new PreferencesController( getWorkspace() ).show();
364
    } catch( final Exception ex ) {
365
      clue( ex );
366
    }
367
  }
368
369
  public void format_bold() {
370
    getActiveTextEditor().bold();
371
  }
372
373
  public void format_italic() {
374
    getActiveTextEditor().italic();
375
  }
376
377
  public void format_monospace() {
378
    getActiveTextEditor().monospace();
379
  }
380
381
  public void format_superscript() {
382
    getActiveTextEditor().superscript();
383
  }
384
385
  public void format_subscript() {
386
    getActiveTextEditor().subscript();
387
  }
388
389
  public void format_strikethrough() {
390
    getActiveTextEditor().strikethrough();
391
  }
392
393
  public void insert_blockquote() {
394
    getActiveTextEditor().blockquote();
395
  }
396
397
  public void insert_code() {
398
    getActiveTextEditor().code();
399
  }
400
401
  public void insert_fenced_code_block() {
402
    getActiveTextEditor().fencedCodeBlock();
403
  }
404
405
  public void insert_link() {
406
    insertObject( createLinkDialog() );
407
  }
408
409
  public void insert_image() {
410
    insertObject( createImageDialog() );
411
  }
412
413
  private void insertObject( final Dialog<String> dialog ) {
414
    final var textArea = getActiveTextEditor().getTextArea();
415
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
416
  }
417
418
  private Dialog<String> createLinkDialog() {
419
    return new LinkDialog( getWindow(), createHyperlinkModel() );
420
  }
421
422
  private Dialog<String> createImageDialog() {
423
    final var path = getActiveTextEditor().getPath();
424
    final var parentDir = path.getParent();
425
    return new ImageDialog( getWindow(), parentDir );
426
  }
427
428
  /**
429
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
430
   * the Markdown AST.
431
   *
432
   * @return An instance containing the link URL and display text.
433
   */
434
  private HyperlinkModel createHyperlinkModel() {
435
    final var context = getMainPane().createProcessorContext();
436
    final var editor = getActiveTextEditor();
437
    final var textArea = editor.getTextArea();
438
    final var selectedText = textArea.getSelectedText();
439
440
    // Convert current paragraph to Markdown nodes.
441
    final var mp = MarkdownProcessor.create( context );
442
    final var p = textArea.getCurrentParagraph();
443
    final var paragraph = textArea.getText( p );
444
    final var node = mp.toNode( paragraph );
445
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
446
    final var link = visitor.process( node );
447
448
    if( link != null ) {
449
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
450
    }
451
452
    return createHyperlinkModel( link, selectedText );
453
  }
454
455
  private HyperlinkModel createHyperlinkModel(
456
    final Link link, final String selection ) {
457
458
    return link == null
459
      ? new HyperlinkModel( selection, "https://localhost" )
460
      : new HyperlinkModel( link );
461
  }
462
463
  public void insert_heading_1() {
464
    insert_heading( 1 );
465
  }
466
467
  public void insert_heading_2() {
468
    insert_heading( 2 );
469
  }
470
471
  public void insert_heading_3() {
472
    insert_heading( 3 );
473
  }
474
475
  private void insert_heading( final int level ) {
476
    getActiveTextEditor().heading( level );
477
  }
478
479
  public void insert_unordered_list() {
480
    getActiveTextEditor().unorderedList();
481
  }
482
483
  public void insert_ordered_list() {
484
    getActiveTextEditor().orderedList();
485
  }
486
487
  public void insert_horizontal_rule() {
488
    getActiveTextEditor().horizontalRule();
489
  }
490
491
  public void definition_create() {
492
    getActiveTextDefinition().createDefinition();
493
  }
494
495
  public void definition_rename() {
496
    getActiveTextDefinition().renameDefinition();
497
  }
498
499
  public void definition_delete() {
500
    getActiveTextDefinition().deleteDefinitions();
501
  }
502
503
  public void definition_autoinsert() {
504
    getMainPane().autoinsert();
505
  }
506
507
  public void view_refresh() {
508
    getMainPane().viewRefresh();
509
  }
510
511
  public void view_preview() {
512
    getMainPane().viewPreview();
513
  }
514
515
  public void view_outline() {
516
    getMainPane().viewOutline();
517
  }
518
519
  public void view_files() {getMainPane().viewFiles();}
520
521
  public void view_statistics() {
522
    getMainPane().viewStatistics();
523
  }
524
525
  public void view_menubar() {
526
    getMainScene().toggleMenuBar();
527
  }
528
529
  public void view_toolbar() {
530
    getMainScene().toggleToolBar();
531
  }
532
533
  public void view_statusbar() {
534
    getMainScene().toggleStatusBar();
535
  }
536
537
  public void view_log() {
538
    mLogView.view();
539
  }
540
541
  public void help_about() {
542
    final var alert = new Alert( INFORMATION );
543
    final var prefix = "Dialog.about.";
544
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
545
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
546
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
547
    alert.setGraphic( ICON_DIALOG_NODE );
548
    alert.initOwner( getWindow() );
549
    alert.showAndWait();
550
  }
551
552
  /**
553
   * Concatenates all the files in the same directory as the given file into
554
   * a string. The extension is determined by the given file name pattern; the
555
   * order files are concatenated is based on their numeric sort order (this
556
   * avoids lexicographic sorting).
557
   * <p>
558
   * If the parent path to the file being edited in the text editor cannot
559
   * be found then this will return the editor's text, without iterating through
560
   * the parent directory. (Should never happen, but who knows?)
561
   * </p>
562
   * <p>
563
   * New lines are automatically appended to separate each file.
564
   * </p>
565
   *
566
   * @param editor The text editor containing
567
   * @return All files in the same directory as the file being edited
568
   * concatenated into a single string.
569
   */
570
  private String append( final TextEditor editor ) {
571
    final var pattern = editor.getPath();
572
    final var parent = pattern.getParent();
573
574
    // Short-circuit because nothing else can be done.
575
    if( parent == null ) {
576
      clue( "Main.status.export.concat.parent", pattern );
577
      return editor.getText();
578
    }
579
580
    final var filename = pattern.getFileName().toString();
581
    final var extension = getExtension( filename );
582
583
    if( extension.isBlank() ) {
584
      clue( "Main.status.export.concat.extension", filename );
585
      return editor.getText();
586
    }
587
588
    try {
589
      final var glob = "**/*." + extension;
590
      final var files = new ArrayList<Path>();
591
      final var text = new StringBuilder( DOCUMENT_LENGTH );
592
      final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS );
593
      final var validator = new RangeValidator( range );
594
      final var chapter = new AtomicInteger();
595
596
      walk( parent, glob, files::add );
597
      files.sort( new AlphanumComparator<>() );
598
      files.forEach( file -> {
599
        try {
600
          clue( "Main.status.export.concat", file );
601
602
          if( validator.test( chapter.incrementAndGet() ) ) {
603
            text.append( readString( file ) );
604
          }
605
        } catch( final IOException ex ) {
606
          clue( "Main.status.export.concat.io", file );
607
        }
608
      } );
609
610
      return text.toString();
611
    } catch( final Throwable t ) {
612
      clue( t );
613
      return editor.getText();
614
    }
615
  }
616
617
  private Optional<List<File>> pickFiles( final SelectionType type ) {
618
    return createPicker( type ).choose();
619
  }
620
621
  @SuppressWarnings( "SameParameterValue" )
622
  private Optional<List<File>> pickFile(
623
    final File filename, final SelectionType type ) {
624
    final var picker = createPicker( type );
625
    picker.setInitialFilename( filename );
626
    return picker.choose();
627
  }
628
629
  private FilePicker createPicker( final SelectionType type ) {
630
    final var factory = new FilePickerFactory( getWorkspace() );
631
    return factory.createModal( getWindow(), type );
632
  }
633
634
  private TextEditor getActiveTextEditor() {
635
    return getMainPane().getTextEditor();
636
  }
637
638
  private TextDefinition getActiveTextDefinition() {
639
    return getMainPane().getTextDefinition();
640
  }
641
642
  private MainScene getMainScene() {
643
    return mMainScene;
644
  }
645
646
  private MainPane getMainPane() {
647
    return mMainPane;
648
  }
649
650
  private Workspace getWorkspace() {
651
    return mMainPane.getWorkspace();
652
  }
653
654
  @SuppressWarnings( "SameParameterValue" )
655
  private String getString( final Key key ) {
656
    return getWorkspace().getString( key );
632657
  }
633658
A src/main/java/com/keenwrite/ui/dialogs/ExportDialog.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.dialogs;
3
4
import com.keenwrite.util.FileWalker;
5
import com.keenwrite.util.RangeValidator;
6
import com.keenwrite.util.ResourceWalker;
7
import javafx.geometry.Insets;
8
import javafx.scene.control.ComboBox;
9
import javafx.scene.control.Label;
10
import javafx.scene.control.TextField;
11
import javafx.scene.image.Image;
12
import javafx.scene.input.KeyCode;
13
import javafx.scene.layout.GridPane;
14
import javafx.scene.text.Font;
15
import javafx.stage.Stage;
16
import javafx.stage.Window;
17
18
import java.io.File;
19
import java.io.FileInputStream;
20
import java.io.IOException;
21
import java.io.InputStreamReader;
22
import java.nio.charset.StandardCharsets;
23
import java.nio.file.Path;
24
import java.util.Properties;
25
import java.util.TreeMap;
26
27
import static com.keenwrite.Messages.get;
28
import static com.keenwrite.constants.Constants.THEME_NAME_LENGTH;
29
import static com.keenwrite.constants.Constants.UI_CONTROL_SPACING;
30
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
31
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
32
import static com.keenwrite.events.StatusEvent.clue;
33
import static com.keenwrite.util.FileWalker.walk;
34
import static java.lang.Math.max;
35
import static java.nio.charset.StandardCharsets.UTF_8;
36
import static javafx.application.Platform.runLater;
37
import static javafx.geometry.Pos.CENTER;
38
import static javafx.scene.control.ButtonType.OK;
39
import static org.codehaus.plexus.util.StringUtils.abbreviate;
40
41
/**
42
 * Provides controls for exporting to PDF, such as selecting a theme and
43
 * creating a subset of chapter numbers.
44
 */
45
public final class ExportDialog extends AbstractDialog<ExportSettings> {
46
  private final File mThemes;
47
  private final ExportSettings mSettings;
48
  private GridPane mPane;
49
  private ComboBox<String> mComboBox;
50
  private TextField mChapters;
51
52
  /**
53
   * Construction must use static method to allow caching themes in the
54
   * future, if needed.
55
   */
56
  private ExportDialog(
57
    final Window owner,
58
    final File themesDir,
59
    final ExportSettings settings,
60
    final boolean multiple
61
  ) {
62
    super( owner, get( "Dialog.typesetting.settings.title" ) );
63
64
    assert themesDir != null;
65
    assert settings != null;
66
67
    mThemes = themesDir;
68
    mSettings = settings;
69
70
    setResultConverter( button -> button == OK ? settings : null );
71
    initComboBox( mComboBox, mSettings, readThemes( themesDir ) );
72
73
    mPane.add( createLabel( "Dialog.typesetting.settings.theme" ), 0, 1 );
74
    mPane.add( mComboBox, 1, 1 );
75
76
    var title = "Dialog.typesetting.settings.header.";
77
78
    if( multiple ) {
79
      mChapters.setText( mSettings.chaptersProperty().get() );
80
      mPane.add( createLabel( "Dialog.typesetting.settings.chapters" ), 0, 2 );
81
      mPane.add( mChapters, 1, 2 );
82
83
      title += "multiple";
84
    }
85
    else {
86
      title += "single";
87
    }
88
89
    setHeaderText( get( title ) );
90
91
    final var dialogPane = getDialogPane();
92
    dialogPane.setContent( mPane );
93
94
    runLater( () -> mComboBox.requestFocus() );
95
  }
96
97
  /**
98
   * Prompts a user to select a theme, answering {@code false} if no theme
99
   * was selected. The themes must be on the native file system; using the
100
   * {@link FileWalker} is a little more optimal than {@link ResourceWalker}.
101
   *
102
   * @param owner    The parent {@link Window} responsible for the dialog.
103
   * @param themes   Theme directory root.
104
   * @param settings Configuration preferences to use when exporting.
105
   * @param multiple Pass {@code true} to input a chapter number subset.
106
   * @return {@code true} if the user accepted or selected a theme.
107
   */
108
  public static boolean choose(
109
    final Window owner,
110
    final File themes,
111
    final ExportSettings settings,
112
    final boolean multiple
113
  ) {
114
    assert themes != null;
115
    assert settings != null;
116
117
    return new ExportDialog( owner, themes, settings, multiple ).pick();
118
  }
119
120
  /**
121
   * @return {@code true} if the user accepted or selected a theme.
122
   * @see #choose(Window, File, ExportSettings, boolean)
123
   */
124
  private boolean pick() {
125
    try {
126
      final var result = showAndWait();
127
128
      // The result will only be set if the OK button is pressed.
129
      if( result.isPresent() ) {
130
        final var theme = mComboBox.getSelectionModel().getSelectedItem();
131
        mSettings.themeProperty().set( theme.toLowerCase() );
132
        mSettings.chaptersProperty().set( mChapters.getText() );
133
134
        return true;
135
      }
136
    } catch( final Exception ex ) {
137
      clue( get( "Main.status.error.theme.missing", mThemes ), ex );
138
    }
139
140
    return false;
141
  }
142
143
  @Override
144
  protected void initComponents() {
145
    initIcon();
146
    setResizable( true );
147
148
    mPane = createContentPane();
149
    mComboBox = createComboBox();
150
    mComboBox.setOnKeyPressed( ( event ) -> {
151
      // When the user presses the down arrow, open the drop-down. This
152
      // prevents navigating to the cancel button.
153
      if( event.getCode() == KeyCode.DOWN && !mComboBox.isShowing() ) {
154
        mComboBox.show();
155
        event.consume();
156
      }
157
    } );
158
159
    mChapters = createNumericTextField();
160
  }
161
162
  private void initIcon() {
163
    setGraphic( ICON_DIALOG_NODE );
164
    setStageGraphic( ICON_DIALOG );
165
  }
166
167
  @SuppressWarnings( "SameParameterValue" )
168
  private void setStageGraphic( final Image icon ) {
169
    if( getDialogPane().getScene().getWindow() instanceof final Stage stage ) {
170
      stage.getIcons().add( icon );
171
    }
172
  }
173
174
  private void initComboBox(
175
    final ComboBox<String> comboBox,
176
    final ExportSettings settings,
177
    final TreeMap<String, String> choices
178
  ) {
179
    assert comboBox != null;
180
    assert settings != null;
181
    assert choices != null;
182
183
    final var selection = new String[]{""};
184
    final var theme = settings.themeProperty().get();
185
186
    // Set the selected item to user's settings value.
187
    for( final var key : choices.keySet() ) {
188
      if( key.equalsIgnoreCase( theme ) ) {
189
        selection[ 0 ] = key;
190
        break;
191
      }
192
    }
193
194
    final var items = comboBox.getItems();
195
    items.addAll( choices.keySet() );
196
    comboBox.getSelectionModel().select(
197
      items.get( max( items.indexOf( selection[ 0 ] ), 0 ) )
198
    );
199
  }
200
201
  private TreeMap<String, String> readThemes( final File themesDir ) {
202
    try {
203
      // List themes in alphabetical order (human-readable by directory name).
204
      final var choices = new TreeMap<String, String>();
205
206
      // Populate the choices with themes detected on the system.
207
      walk( themesDir.toPath(), "**/theme.properties", ( path ) -> {
208
        try {
209
          final var displayed = readThemeName( path );
210
          final var themeName = path.getParent().toFile().getName();
211
          choices.put( abbreviate( displayed, THEME_NAME_LENGTH ), themeName );
212
        } catch( final Exception ex ) {
213
          clue( "Main.status.error.theme.name", path );
214
        }
215
      } );
216
217
      return choices;
218
    } catch( final Exception ex ) {
219
      clue( ex );
220
    }
221
222
    return new TreeMap<>();
223
  }
224
225
  private ComboBox<String> createComboBox() {
226
    return new ComboBox<>();
227
  }
228
229
  private GridPane createContentPane() {
230
    final var grid = new GridPane();
231
232
    grid.setAlignment( CENTER );
233
    grid.setHgap( UI_CONTROL_SPACING );
234
    grid.setVgap( UI_CONTROL_SPACING );
235
    grid.setPadding( new Insets( 25, 25, 25, 25 ) );
236
237
    return grid;
238
  }
239
240
  /**
241
   * Creates an input field that only accepts whole numbers. This allows users
242
   * to enter in chapter ranges such as: <code>1-5, 7, 9-10</code>.
243
   *
244
   * @return A {@link TextField} that censors non-conforming characters.
245
   */
246
  private TextField createNumericTextField() {
247
    final var textField = new TextField();
248
249
    textField.textProperty().addListener(
250
      ( c, o, n ) -> textField.setText( RangeValidator.normalize( n ) )
251
    );
252
253
    return textField;
254
  }
255
256
  private Label createLabel( final String key ) {
257
    final var label = new Label( get( key ) + ":" );
258
    final var font = label.getFont();
259
    final var upscale = new Font( font.getName(), 14 );
260
261
    label.setFont( upscale );
262
263
    return label;
264
  }
265
266
  /**
267
   * Returns the theme's human-friendly name from a file conforming to
268
   * {@link Properties}.
269
   *
270
   * @param file A fully qualified file name readable using {@link Properties}.
271
   * @return The human-friendly theme name.
272
   * @throws IOException          The {@link Properties} file cannot be read.
273
   * @throws NullPointerException The name field is not defined.
274
   */
275
  private String readThemeName( final Path file ) throws Exception {
276
    return read( file ).get( "name" ).toString();
277
  }
278
279
  /**
280
   * Reads an instance of {@link Properties} from the given {@link Path} using
281
   * {@link StandardCharsets#UTF_8} encoding.
282
   *
283
   * @param path The fully qualified path to the file.
284
   * @return The path to the file to read.
285
   * @throws IOException Could not open the file for reading.
286
   */
287
  private Properties read( final Path path ) throws IOException {
288
    final var properties = new Properties();
289
290
    try(
291
      final var f = new FileInputStream( path.toFile() );
292
      final var in = new InputStreamReader( f, UTF_8 )
293
    ) {
294
      properties.load( in );
295
    }
296
297
    return properties;
298
  }
299
}
1300
A src/main/java/com/keenwrite/ui/dialogs/ExportSettings.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.dialogs;
3
4
import com.keenwrite.util.GenericBuilder;
5
import javafx.beans.property.StringProperty;
6
7
/**
8
 * Provides export settings such as the selected theme and chapter numbers
9
 * to include.
10
 */
11
public class ExportSettings {
12
  private final Mutator mMutator;
13
14
  public static class Mutator {
15
    private StringProperty mThemeProperty;
16
    private StringProperty mChaptersProperty;
17
18
    public void setTheme( final StringProperty theme ) {
19
      assert theme != null;
20
      mThemeProperty = theme;
21
    }
22
23
    public void setChapters( final StringProperty chapters ) {
24
      assert chapters != null;
25
      mChaptersProperty = chapters;
26
    }
27
  }
28
29
  /**
30
   * Force using the builder pattern.
31
   */
32
  private ExportSettings( final Mutator mutator ) {
33
    assert mutator != null;
34
35
    mMutator = mutator;
36
  }
37
38
  public static GenericBuilder<Mutator, ExportSettings> builder() {
39
    return GenericBuilder.of(
40
      ExportSettings.Mutator::new, ExportSettings::new
41
    );
42
  }
43
44
  public StringProperty themeProperty() {
45
    return mMutator.mThemeProperty;
46
  }
47
48
  public StringProperty chaptersProperty() {
49
    return mMutator.mChaptersProperty;
50
  }
51
}
52
153
D src/main/java/com/keenwrite/ui/dialogs/ThemePicker.java
1
/* Copyright 2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.dialogs;
3
4
import com.keenwrite.util.FileWalker;
5
import com.keenwrite.util.ResourceWalker;
6
import javafx.beans.property.StringProperty;
7
import javafx.scene.control.ChoiceDialog;
8
import javafx.scene.control.ComboBox;
9
import javafx.scene.image.Image;
10
import javafx.scene.input.KeyCode;
11
import javafx.stage.Stage;
12
13
import java.io.File;
14
import java.io.FileInputStream;
15
import java.io.IOException;
16
import java.io.InputStreamReader;
17
import java.nio.charset.StandardCharsets;
18
import java.nio.file.Path;
19
import java.util.Properties;
20
import java.util.TreeMap;
21
22
import static com.keenwrite.Messages.get;
23
import static com.keenwrite.constants.Constants.THEME_NAME_LENGTH;
24
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
25
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
26
import static com.keenwrite.events.StatusEvent.clue;
27
import static com.keenwrite.util.FileWalker.walk;
28
import static java.lang.Math.max;
29
import static java.nio.charset.StandardCharsets.UTF_8;
30
import static org.codehaus.plexus.util.StringUtils.abbreviate;
31
32
/**
33
 * Responsible for allowing the user to pick from the available themes found
34
 * in the system.
35
 */
36
public class ThemePicker extends ChoiceDialog<String> {
37
  private final File mThemes;
38
  private final StringProperty mTheme;
39
40
  /**
41
   * Construction must use static method to allow caching themes in the
42
   * future, if needed.
43
   *
44
   * @see #choose(File, StringProperty)
45
   */
46
  @SuppressWarnings( "rawtypes" )
47
  private ThemePicker( final File themes, final StringProperty theme ) {
48
    assert themes != null;
49
    assert theme != null;
50
51
    mThemes = themes;
52
    mTheme = theme;
53
    initIcon();
54
    setTitle( get( "Dialog.theme.title" ) );
55
    setHeaderText( get( "Dialog.theme.header" ) );
56
57
    final var options = (ComboBox) getDialogPane().lookup( ".combo-box" );
58
    options.setOnKeyPressed( ( event ) -> {
59
      // When the user presses the down arrow, open the drop-down. This prevents
60
      // navigating to the cancel button.
61
      if( event.getCode() == KeyCode.DOWN && !options.isShowing() ) {
62
        options.show();
63
        event.consume();
64
      }
65
    } );
66
  }
67
68
  private void initIcon() {
69
    setGraphic( ICON_DIALOG_NODE );
70
    setStageGraphic( ICON_DIALOG );
71
  }
72
73
  @SuppressWarnings( "SameParameterValue" )
74
  private void setStageGraphic( final Image icon ) {
75
    if( getDialogPane().getScene().getWindow() instanceof final Stage stage ) {
76
      stage.getIcons().add( icon );
77
    }
78
  }
79
80
  /**
81
   * Prompts a user to select a theme, answering {@code false} if no theme
82
   * was selected. The themes must be on the native file system; using the
83
   * {@link FileWalker} is a little more optimal than {@link ResourceWalker}.
84
   *
85
   * @param themes Theme directory root.
86
   * @param theme  Selected theme property name.
87
   * @return {@code true} if the user accepted or selected a theme.
88
   */
89
  public static boolean choose(
90
    final File themes, final StringProperty theme ) {
91
    assert themes != null;
92
    assert theme != null;
93
94
    return new ThemePicker( themes, theme ).pick();
95
  }
96
97
  /**
98
   * @return {@code true} if the user accepted or selected a theme.
99
   * @see #choose(File, StringProperty)
100
   */
101
  private boolean pick() {
102
    try {
103
      // List themes in alphabetical order (human-readable by directory name).
104
      final var choices = new TreeMap<String, String>();
105
      final String[] selection = new String[]{""};
106
107
      // Populate the choices with themes detected on the system.
108
      walk( mThemes.toPath(), "**/theme.properties", ( path ) -> {
109
        try {
110
          final var displayed = readThemeName( path );
111
          final var themeName = path.getParent().toFile().getName();
112
          choices.put( abbreviate( displayed, THEME_NAME_LENGTH ), themeName );
113
114
          // Set the selected item to user's settings value.
115
          if( themeName.equals( mTheme.get() ) ) {
116
            selection[ 0 ] = displayed;
117
          }
118
        } catch( final Exception ex ) {
119
          clue( "Main.status.error.theme.name", path );
120
        }
121
      } );
122
123
      final var items = getItems();
124
      items.addAll( choices.keySet() );
125
      setSelectedItem( items.get( max( items.indexOf( selection[ 0 ] ), 0 ) ) );
126
127
      final var result = showAndWait();
128
129
      if( result.isPresent() ) {
130
        mTheme.set( choices.get( result.get() ) );
131
        return true;
132
      }
133
    } catch( final Exception ex ) {
134
      clue( get( "Main.status.error.theme.missing", mThemes ), ex );
135
    }
136
137
    return false;
138
  }
139
140
  /**
141
   * Returns the theme's human-friendly name from a file conforming to
142
   * {@link Properties}.
143
   *
144
   * @param file A fully qualified file name readable using {@link Properties}.
145
   * @return The human-friendly theme name.
146
   * @throws IOException          The {@link Properties} file cannot be read.
147
   * @throws NullPointerException The name field is not defined.
148
   */
149
  private String readThemeName( final Path file ) throws Exception {
150
    return read( file ).get( "name" ).toString();
151
  }
152
153
  /**
154
   * Reads an instance of {@link Properties} from the given {@link Path} using
155
   * {@link StandardCharsets#UTF_8} encoding.
156
   *
157
   * @param path The fully qualified path to the file.
158
   * @return The path to the file to read.
159
   * @throws IOException Could not open the file for reading.
160
   */
161
  private Properties read( final Path path ) throws IOException {
162
    final var properties = new Properties();
163
164
    try( final var in = new InputStreamReader(
165
      new FileInputStream( path.toFile() ), UTF_8 ) ) {
166
      properties.load( in );
167
    }
168
169
    return properties;
170
  }
171
}
1721
A src/main/java/com/keenwrite/util/RangeValidator.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.util;
3
4
import java.util.ArrayList;
5
import java.util.List;
6
import java.util.function.Predicate;
7
8
/**
9
 * Responsible for answering whether a given integer value falls within a
10
 * set of range specifiers. For example, if the range is "1-3, 5, 7-9, 11-",
11
 * then values of 0, 4, and 10 return {@code false} while values of 2, 5,
12
 * and 37 would return {@code true}.
13
 */
14
public final class RangeValidator implements Predicate<Integer> {
15
16
  /**
17
   * Container for a pair of integer values that can answer whether a given
18
   * value is included within the bounds provided by the pair.
19
   */
20
  private static class Range {
21
    private final int mLo;
22
    private final int mHi;
23
24
    private Range( final int lo, final int hi ) {
25
      assert lo <= hi;
26
27
      mLo = lo;
28
      mHi = hi;
29
    }
30
31
    private boolean includes( final int i ) {
32
      return mLo <= i && i <= mHi || mLo == -1 && mHi == -1;
33
    }
34
  }
35
36
  private final List<Range> mRanges = new ArrayList<>();
37
38
  /**
39
   * Creates an instance of {@link RangeValidator} that can verify whether
40
   * an integer value will fall within one of the numeric ranges in the
41
   * given listing.
42
   *
43
   * @param range The listing of ranges to validate against.
44
   */
45
  public RangeValidator( final String range ) {
46
    assert normalize( range ).equals( range );
47
48
    parse( range );
49
  }
50
51
  @Override
52
  public boolean test( final Integer integer ) {
53
    for( final var range : mRanges ) {
54
      if( range.includes( integer ) ) {
55
        return true;
56
      }
57
    }
58
59
    return false;
60
  }
61
62
  /**
63
   * Given a string meant to represent a comma-separated range of numbers,
64
   * this will ensure that the range meets the formatting requirements.
65
   *
66
   * @param range The sequences to validate (can be {@code null}).
67
   * @return The given range with all non-conforming characters removed, or
68
   * the empty string if {@code null} was provided.
69
   */
70
  public static String normalize( final String range ) {
71
    return range == null
72
      ? ""
73
      : range.matches( "^\\d+(-\\d+)?(?:,\\d+(?:-\\d+)?)*+$" )
74
      ? range
75
      : range.replaceAll( "[^-,\\d\\s]", "" );
76
  }
77
78
  /**
79
   * Populates the internal list of {@link Range} instances.
80
   *
81
   * @param s The string containing zero or more comma-separated integer
82
   *          ranges, themselves separated by hyphens.
83
   */
84
  private void parse( final String s ) {
85
    for( final var commaRange : normalize( s ).split( "," ) ) {
86
      final var hyphenRanges = commaRange.split( "-" );
87
      final Range range;
88
89
      if( hyphenRanges.length == 2 ) {
90
        final var hrlo = hyphenRanges[ 0 ].trim();
91
        final var hrhi = hyphenRanges[ 1 ].trim();
92
93
        if( hrlo.isEmpty() ) {
94
          range = new Range( 1, Integer.parseInt( hrhi ) );
95
        }
96
        else {
97
          final var lo = Integer.parseInt( hrlo );
98
          final var hi = Integer.parseInt( hrhi );
99
100
          range = new Range( lo, hi );
101
        }
102
      }
103
      else if( hyphenRanges.length == 1 ) {
104
        final var hri = hyphenRanges[ 0 ].trim();
105
106
        if( hri.isEmpty() ) {
107
          // Special case for all numbers being valid.
108
          range = new Range( -1, -1 );
109
        }
110
        else {
111
          final var i = Integer.parseInt( hyphenRanges[ 0 ].trim() );
112
          final var index = commaRange.trim().indexOf( '-' );
113
114
          // If the hyphen is to the left of the number, the range is bounded
115
          // from 0 to the number. Otherwise, the range is "unbounded" starting
116
          // at the number.
117
          if( index == -1 ) {
118
            range = new Range( i, i );
119
          }
120
          else if( index == 0 ) {
121
            range = new Range( 1, i );
122
          }
123
          else {
124
            range = new Range( i, Integer.MAX_VALUE );
125
          }
126
        }
127
      }
128
      else {
129
        // Ignore the range.
130
        range = new Range( 0, 0 );
131
      }
132
133
      mRanges.add( range );
134
    }
135
  }
136
}
1137
M src/main/resources/com/keenwrite/messages.properties
329329
330330
# ########################################################################
331
# Themes Dialog
331
# Typesetting Settings Dialog
332332
# ########################################################################
333333
334
Dialog.theme.title=Typesetting theme
335
Dialog.theme.header=Choose a typesetting theme
334
Dialog.typesetting.settings.title=Typesetting export settings
335
Dialog.typesetting.settings.header.single=Export current document
336
Dialog.typesetting.settings.theme=Theme
337
338
Dialog.typesetting.settings.header.multiple=Export multiple documents
339
Dialog.typesetting.settings.chapters=Chapters (e.g., 1-3, 5, 7-)
336340
337341
# ########################################################################
A src/test/java/com/keenwrite/util/RangeValidatorTest.java
1
package com.keenwrite.util;
2
3
import org.junit.jupiter.api.Test;
4
5
import static org.junit.jupiter.api.Assertions.*;
6
7
/**
8
 * Tests that the range format specifiers correctly identify integer values
9
 * inside and outside the range.
10
 */
11
class RangeValidatorTest {
12
  @Test
13
  void test_Validation_SingleRange_Valid() {
14
    // Arbitrary start and end.
15
    final var lo = 1;
16
    final var hi = 5;
17
    final var validator = new RangeValidator( lo + "-" + hi );
18
19
    for( int i = lo; i < hi; i++ ) {
20
      assertTrue( validator.test( i ) );
21
    }
22
23
    // Arbitrary bounds checks.
24
    assertFalse( validator.test( lo - 1 ) );
25
    assertFalse( validator.test( lo - 11 ) );
26
    assertFalse( validator.test( hi + 1 ) );
27
    assertFalse( validator.test( hi + 11 ) );
28
  }
29
30
  @Test
31
  void test_Validation_SingleValue_Valid() {
32
    // Arbitrary.
33
    final var i = 7;
34
    final var validator = new RangeValidator( Integer.toString( i ) );
35
36
    assertTrue( validator.test( i ) );
37
  }
38
39
  @Test
40
  void test_Validation_UnboundedMaxIntegerRange_Valid() {
41
    // Arbitrary.
42
    final var lo = 11;
43
    final var validator = new RangeValidator( lo + "-" );
44
45
    // Arbitrary end value.
46
    for( int i = lo; i < lo + 101; i++ ) {
47
      assertTrue( validator.test( i ) );
48
    }
49
50
    assertFalse( validator.test( 10 ) );
51
  }
52
53
  @Test
54
  void test_Validation_UnboundedMinIntegerRange_Valid() {
55
    // Arbitrary.
56
    final var hi = 5;
57
    final var validator = new RangeValidator( "-" + hi );
58
59
    for( int i = 1; i < hi; i++ ) {
60
      assertTrue( validator.test( i ) );
61
    }
62
63
    assertFalse( validator.test( 0 ) );
64
    assertFalse( validator.test( -1 ) );
65
  }
66
67
  @Test
68
  void test_Validation_MultipleRanges_Valid() {
69
    // Arbitrary.
70
    final var validator = new RangeValidator( "-5, 7-11, 13, 15-20, 30-" );
71
72
    assertTrue( validator.test( 1 ) );
73
    assertTrue( validator.test( 5 ) );
74
    assertTrue( validator.test( 7 ) );
75
    assertTrue( validator.test( 11 ) );
76
    assertTrue( validator.test( 13 ) );
77
    assertTrue( validator.test( 15 ) );
78
    assertTrue( validator.test( 20 ) );
79
    assertTrue( validator.test( 30 ) );
80
    assertTrue( validator.test( 101 ) );
81
82
    assertFalse( validator.test( -1 ) );
83
    assertFalse( validator.test( 0 ) );
84
    assertFalse( validator.test( 6 ) );
85
    assertFalse( validator.test( 12 ) );
86
    assertFalse( validator.test( 14 ) );
87
    assertFalse( validator.test( 21 ) );
88
    assertFalse( validator.test( 29 ) );
89
  }
90
91
  @Test
92
  void test_Validation_EmptyRange_AllValid() {
93
    final var validator = new RangeValidator( "" );
94
95
    assertTrue( validator.test( 0 ) );
96
    assertTrue( validator.test( 1 ) );
97
    assertTrue( validator.test( 2 ) );
98
    assertTrue( validator.test( Integer.MAX_VALUE ) );
99
  }
100
}
1101