Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M R/csv.R
3333
# @param align Right-align numbers (default TRUE).
3434
# -----------------------------------------------------------------------------
35
csv2md <- function( f, decimals = 2, totals = T, align = T ) {
35
csv2md <- function( f, decimals = 2, totals = T, align = T, caption = "" ) {
3636
  # Read the CVS data from the file; ensure strings become characters.
3737
  df <- read.table( f, sep=',', header=T, stringsAsFactors=F )
...
7474
  else {
7575
    dashes <- paste( rep( '---', length( df ) ), collapse = '|' )
76
  }
77
78
  # Use pandoc syntax for table captions.
79
  if( caption != "" ) {
80
    caption <- paste( '\n[', caption, ']\n', sep='' )
7681
  }
7782
...
8691
        }, df
8792
      ),
88
      collapse = '|\n', sep=''
93
      collapse = '|\n',sep=''
8994
    ),
90
    '|\n',
95
    '|',
96
    caption,
9197
    sep=''
9298
  )
M docs/README.md
33
The following documents have additional details about using the editor:
44
5
* [cmd.md](cmd.md) -- Command-line argument usage
56
* [div.md](div.md) -- Syntax for annotated text (fenced divs)
67
* [i18n.md](i18n.md) -- Internationalization features
A docs/cmd.md
1
# Command-line arguments
2
3
The application may be run from the command-line to convert Markdown and
4
R Markdown files to a variety of output formats. Without specifying any
5
command-line arguments, the application will launch a graphical user interface.
6
7
## Common arguments
8
9
The most common command-line arguments to use include:
10
11
* `-h` -- displays all command-line arguments, then exits.
12
* `-i` -- sets the input file name, must be a full path.
13
* `-o` -- sets the output file name, can be a relative path.
14
15
## Example usage
16
17
On Linux, simple usages include:
18
19
    keenwrite.bin -i $HOME/document/01.md -o document.xhtml
20
21
    keenwrite.bin -i $HOME/document/01.md -o document.md \
22
      -v $HOME/document/variables.yaml
23
24
That command will convert `01.md` into the respective file formats. In
25
the first case, it will become an HTML page. In the second case, it will
26
become a Markdown document with all variables interpolated and replaced.
27
28
A more complex example follows:
29
30
    keenwrite.bin -i $HOME/document/01.Rmd -o document.pdf \
31
      --image-dir=$HOME/document/images -v $HOME/document/variables.yaml \
32
      --metadata="title={{book.title}}" --metadata="author={{book.author}}" \
33
      --r-dir=$HOME/document/r --r-script=$HOME/document/r/bootstrap.R \
34
      --theme-dir=$HOME/document/themes/boschet
35
36
That command will convert `01.Rmd` to `document.pdf` and replace the metadata
37
using values from the variable definitions file.
38
39
Directory names containing spaces must be quoted. For example, on Windows:
40
41
    keenwrite.bin -i "C:\Users\My Documents\01.Rmd" -o document.pdf
42
143
M installer.sh
3333
ARG_JAVA_OS="linux"
3434
ARG_JAVA_ARCH="amd64"
35
ARG_JAVA_VERSION="17"
36
ARG_JAVA_UPDATE="35"
35
ARG_JAVA_VERSION="17.0.1"
36
ARG_JAVA_UPDATE="12"
3737
ARG_JAVA_DIR="java"
3838
...
157157
readonly SCRIPT_SRC="\$(dirname "\${BASH_SOURCE[\${#BASH_SOURCE[@]} - 1]}")"
158158
159
"\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" 2>/dev/null &
159
"\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" 2>/dev/null
160160
__EOT
161161
D src/main/java/com/keenwrite/AbstractFileFactory.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.io.FileType;
5
6
import java.nio.file.Path;
7
8
import static com.keenwrite.constants.Constants.GLOB_PREFIX_FILE;
9
import static com.keenwrite.constants.Constants.sSettings;
10
import static com.keenwrite.io.FileType.UNKNOWN;
11
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
12
13
/**
14
 * Provides common behaviours for factories that instantiate classes based on
15
 * file type.
16
 */
17
public abstract class AbstractFileFactory {
18
19
  /**
20
   * Determines the file type from the path extension. This should only be
21
   * called when it is known that the file type won't be a definition file
22
   * (e.g., YAML or other definition source), but rather an editable file
23
   * (e.g., Markdown, R Markdown, etc.).
24
   *
25
   * @param path The path with a file name extension.
26
   * @return The FileType for the given path.
27
   */
28
  public static FileType lookup( final Path path ) {
29
    assert path != null;
30
31
    return lookup( path, GLOB_PREFIX_FILE );
32
  }
33
34
  /**
35
   * Creates a file type that corresponds to the given path.
36
   *
37
   * @param path   Reference to a variable definition file.
38
   * @param prefix One of GLOB_PREFIX_DEFINITION or GLOB_PREFIX_FILE.
39
   * @return The file type that corresponds to the given path.
40
   */
41
  protected static FileType lookup( final Path path, final String prefix ) {
42
    assert path != null;
43
    assert prefix != null;
44
45
    final var keys = sSettings.getKeys( prefix );
46
47
    var found = false;
48
    var fileType = UNKNOWN;
49
50
    while( keys.hasNext() && !found ) {
51
      final var key = keys.next();
52
      final var patterns = sSettings.getStringSettingList( key );
53
      final var predicate = createFileTypePredicate( patterns );
54
55
      if( predicate.test( path.toFile() ) ) {
56
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
57
        // to a standard name (as defined in the settings.properties file).
58
        final String suffix = key.replace( prefix + '.', "" );
59
        fileType = FileType.from( suffix );
60
        found = true;
61
      }
62
    }
63
64
    return fileType;
65
  }
66
}
671
M src/main/java/com/keenwrite/AppCommands.java
22
33
import com.keenwrite.cmdline.Arguments;
4
import com.keenwrite.processors.ProcessorContext;
5
import com.keenwrite.typesetting.Typesetter;
6
import com.keenwrite.ui.dialogs.ThemePicker;
74
import com.keenwrite.util.AlphanumComparator;
85
96
import java.io.IOException;
107
import java.nio.file.Path;
118
import java.util.ArrayList;
129
import java.util.concurrent.Callable;
1310
import java.util.concurrent.CompletableFuture;
1411
import java.util.concurrent.ExecutorService;
12
import java.util.concurrent.atomic.AtomicInteger;
1513
16
import static com.keenwrite.ExportFormat.*;
14
import static com.keenwrite.Launcher.terminate;
15
import static com.keenwrite.events.StatusEvent.clue;
1716
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
1817
import static com.keenwrite.util.FileWalker.walk;
...
4140
4241
  public static void run( final Arguments args ) {
43
    final var context = args.createProcessorContext();
42
    final var exitCode = new AtomicInteger();
43
44
    final var future = new CompletableFuture<Path>() {
45
      @Override
46
      public boolean complete( final Path path ) {
47
        return super.complete( path );
48
      }
49
50
      @Override
51
      public boolean completeExceptionally( final Throwable ex ) {
52
        clue( ex );
53
        exitCode.set( 1 );
54
55
        return super.completeExceptionally( ex );
56
      }
57
    };
58
59
    file_export( args, future );
60
    sExecutor.shutdown();
61
    future.join();
62
    terminate( exitCode.get() );
4463
  }
4564
4665
  /**
4766
   * Converts one or more files into the given file format. If {@code dir}
4867
   * is set to true, this will first append all files in the same directory
4968
   * as the actively edited file.
5069
   *
51
   * @param inputPath The source document to export in the given file format.
52
   * @param format    The destination file format.
53
   * @param concat    Export all files in the actively edited file's directory.
54
   * @param future    Indicates whether the export succeeded or failed.
70
   * @param future Indicates whether the export succeeded or failed.
5571
   */
56
  private void file_export(
57
    final Path inputPath,
58
    final ExportFormat format,
59
    final boolean concat,
60
    final CompletableFuture<Path> future ) {
72
  private static void file_export(
73
    final Arguments args, final CompletableFuture<Path> future ) {
74
    assert args != null;
75
    assert future != null;
76
6177
    final Callable<Path> callableTask = () -> {
6278
      try {
63
        final var context = ProcessorContext.create( inputPath, format );
64
        final var outputPath = format.toExportPath( inputPath );
79
        final var context = args.createProcessorContext();
80
        final var concat = context.getConcatenate();
81
        final var inputPath = context.getInputPath();
82
        final var outputPath = context.getOutputPath();
6583
        final var chain = createProcessors( context );
6684
        final var inputDoc = read( inputPath, concat );
6785
        final var outputDoc = chain.apply( inputDoc );
6886
6987
        // Processors can export binary files. In such cases, processors will
7088
        // return null to prevent further processing.
7189
        final var result =
7290
          outputDoc == null ? null : writeString( outputPath, outputDoc );
7391
74
        future.complete( result );
92
        future.complete( outputPath );
7593
        return result;
76
      } catch( final Exception ex ) {
94
      } catch( final Throwable ex ) {
7795
        future.completeExceptionally( ex );
7896
        return null;
7997
      }
8098
    };
8199
82100
    // Prevent the application from blocking while the processor executes.
83101
    sExecutor.submit( callableTask );
84
  }
85
86
  /**
87
   * @param concat {@code true} means to export all files in the active file
88
   *               editor's directory; {@code false} means to export only the
89
   *               actively edited file.
90
   *
91
  private void file_export_pdf( final Path theme, final boolean concat ) {
92
    if( Typesetter.canRun() ) {
93
      // If the typesetter is installed, allow the user to select a theme. If
94
      // the themes aren't installed, a status message will appear.
95
      if( ThemePicker.choose( themes, theme ) ) {
96
        file_export( APPLICATION_PDF, concat );
97
      }
98
    }
99
    else {
100
      fireExportFailedEvent();
101
    }
102
  }
103
104
  public void file_export_pdf() {
105
    file_export_pdf( false );
106
  }
107
108
  public void file_export_pdf_dir() {
109
    file_export_pdf( true );
110
  }
111
112
  public void file_export_html_svg() {
113
    file_export( HTML_TEX_SVG );
114
  }
115
116
  public void file_export_html_tex() {
117
    file_export( HTML_TEX_DELIMITED );
118
  }
119
120
  public void file_export_xhtml_tex() {
121
    file_export( XHTML_TEX );
122102
  }
123103
124
  public void file_export_markdown() {
125
    file_export( MARKDOWN_PLAIN );
126
  }
127
*/
128104
  /**
129105
   * Concatenates all the files in the same directory as the given file into
...
146122
   * concatenated into a single string.
147123
   */
148
  private String read( final Path inputPath, final boolean concat )
124
  private static String read( final Path inputPath, final boolean concat )
149125
    throws IOException {
150126
    final var parent = inputPath.getParent();
D src/main/java/com/keenwrite/Caret.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.util.GenericBuilder;
5
import javafx.beans.value.ObservableValue;
6
import org.fxmisc.richtext.StyleClassedTextArea;
7
import org.fxmisc.richtext.model.Paragraph;
8
import org.reactfx.collection.LiveList;
9
10
import java.util.Collection;
11
12
import static com.keenwrite.Messages.get;
13
import static com.keenwrite.constants.Constants.STATUS_BAR_LINE;
14
15
/**
16
 * Represents the absolute, relative, and maximum position of the caret. The
17
 * caret position is a character offset into the text.
18
 */
19
public class Caret {
20
21
  private final Mutator mMutator;
22
23
  public static GenericBuilder<Caret.Mutator, Caret> builder() {
24
    return GenericBuilder.of( Caret.Mutator::new, Caret::new );
25
  }
26
27
  /**
28
   * Used for building a new {@link Caret} instance.
29
   */
30
  public static class Mutator {
31
    /**
32
     * Caret's current paragraph index (i.e., current caret line number).
33
     */
34
    private ObservableValue<Integer> mParagraph;
35
36
    /**
37
     * Used to count the number of lines in the text editor document.
38
     */
39
    private LiveList<Paragraph<Collection<String>, String,
40
      Collection<String>>> mParagraphs;
41
42
    /**
43
     * Caret offset into the full text, represented as a string index.
44
     */
45
    private ObservableValue<Integer> mTextOffset;
46
47
    /**
48
     * Caret offset into the current paragraph, represented as a string index.
49
     */
50
    private ObservableValue<Integer> mParaOffset;
51
52
    /**
53
     * Total number of characters in the document.
54
     */
55
    private ObservableValue<Integer> mTextLength;
56
57
    /**
58
     * Configures this caret position using properties from the given editor.
59
     *
60
     * @param editor The text editor that has a caret with position properties.
61
     */
62
    public void setEditor( final StyleClassedTextArea editor ) {
63
      mParagraph = editor.currentParagraphProperty();
64
      mParagraphs = editor.getParagraphs();
65
      mParaOffset = editor.caretColumnProperty();
66
      mTextOffset = editor.caretPositionProperty();
67
      mTextLength = editor.lengthProperty();
68
    }
69
  }
70
71
  /**
72
   * Force using the builder pattern.
73
   */
74
  private Caret( final Mutator mutator ) {
75
    assert mutator != null;
76
77
    mMutator = mutator;
78
  }
79
80
  /**
81
   * Allows observers to be notified when the value of the caret changes.
82
   *
83
   * @return An observer for the caret's document offset.
84
   */
85
  public ObservableValue<Integer> textOffsetProperty() {
86
    return mMutator.mTextOffset;
87
  }
88
89
  /**
90
   * Answers whether the caret's offset into the text is between the given
91
   * offsets.
92
   *
93
   * @param began Starting value compared against the caret's text offset.
94
   * @param ended Ending value compared against the caret's text offset.
95
   * @return {@code true} when the caret's text offset is between the given
96
   * values, inclusively (for either value).
97
   */
98
  public boolean isBetweenText( final int began, final int ended ) {
99
    final var offset = getTextOffset();
100
    return began <= offset && offset <= ended;
101
  }
102
103
  /**
104
   * Answers whether the caret's offset into the paragraph is before the given
105
   * offset.
106
   *
107
   * @param offset Compared against the caret's paragraph offset.
108
   * @return {@code true} the caret's offset is before the given offset.
109
   */
110
  public boolean isBeforeColumn( final int offset ) {
111
    return getParaOffset() < offset;
112
  }
113
114
  /**
115
   * Answers whether the caret's offset into the text is before the given
116
   * text offset.
117
   *
118
   * @param offset Compared against the caret's text offset.
119
   * @return {@code true} the caret's offset is after the given offset.
120
   */
121
  public boolean isAfterColumn( final int offset ) {
122
    return getParaOffset() > offset;
123
  }
124
125
  /**
126
   * Answers whether the caret's offset into the text exceeds the length of
127
   * the text.
128
   *
129
   * @return {@code true} when the caret is at the end of the text boundary.
130
   */
131
  public boolean isAfterText() {
132
    return getTextOffset() >= getTextLength();
133
  }
134
135
  public boolean isAfter( final int offset ) {
136
    return offset >= getTextOffset();
137
  }
138
139
  private int getParagraph() {
140
    return mMutator.mParagraph.getValue();
141
  }
142
143
  /**
144
   * Returns the number of lines in the text editor.
145
   *
146
   * @return The size of the text editor's paragraph list plus one.
147
   */
148
  private int getParagraphCount() {
149
    return mMutator.mParagraphs.size() + 1;
150
  }
151
152
  /**
153
   * Returns the absolute position of the caret within the entire document.
154
   *
155
   * @return A zero-based index of the caret position.
156
   */
157
  private int getTextOffset() {
158
    return mMutator.mTextOffset.getValue();
159
  }
160
161
  /**
162
   * Returns the position of the caret within the current paragraph being
163
   * edited.
164
   *
165
   * @return A zero-based index of the caret position relative to the
166
   * current paragraph.
167
   */
168
  private int getParaOffset() {
169
    return mMutator.mParaOffset.getValue();
170
  }
171
172
  /**
173
   * Returns the total number of characters in the document being edited.
174
   *
175
   * @return A zero-based count of the total characters in the document.
176
   */
177
  private int getTextLength() {
178
    return mMutator.mTextLength.getValue();
179
  }
180
181
  /**
182
   * Returns a human-readable string that shows the current caret position
183
   * within the text. Typically this will include the current line number,
184
   * the number of lines, and the character offset into the text.
185
   * <p>
186
   * If the {@link Caret} has not been properly built, this will return a
187
   * string for the status bar having all values set to zero. This can happen
188
   * during unit testing, but should not happen any other time.
189
   * </p>
190
   *
191
   * @return A string to present to an end user.
192
   */
193
  @Override
194
  public String toString() {
195
    try {
196
      return get( STATUS_BAR_LINE,
197
                  getParagraph() + 1,
198
                  getParagraphCount(),
199
                  getTextOffset() + 1 );
200
    } catch( final Exception ex ) {
201
      return get( STATUS_BAR_LINE, 0, 0, 0 );
202
    }
203
  }
204
}
2051
M src/main/java/com/keenwrite/Launcher.java
1414
1515
import static com.keenwrite.Bootstrap.*;
16
import static com.keenwrite.PermissiveCertificate.installTrustManager;
16
import static com.keenwrite.security.PermissiveCertificate.installTrustManager;
1717
import static java.lang.String.format;
1818
...
3131
   */
3232
  private final String[] mArgs;
33
34
  private static void parse( final String[] args ) {
35
    assert args != null;
36
37
    final var arguments = new Arguments( new Launcher( args ) );
38
    final var parser = new CommandLine( arguments );
39
40
    parser.setColorScheme( ColourScheme.create() );
41
42
    final var exitCode = parser.execute( args );
43
    final var parseResult = parser.getParseResult();
44
45
    if( parseResult.isUsageHelpRequested() ) {
46
      System.exit( exitCode );
47
    }
48
  }
49
50
  /**
51
   * Suppress writing to standard error, suppresses writing log messages.
52
   */
53
  private static void disableLogging() {
54
    LogManager.getLogManager().reset();
55
    System.err.close();
56
  }
57
58
  private static void showAppInfo() {
59
    out( "%n%s version %s", APP_TITLE, APP_VERSION );
60
    out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR );
61
    out( "Portions copyright 2015-2020 Karl Tauber.%n" );
62
  }
6333
6434
  /**
...
7646
    } catch( final Exception ex ) {
7747
      throw new RuntimeException( ex );
48
    }
49
  }
50
51
  /**
52
   * Immediately exits the application.
53
   *
54
   * @param exitCode Code to provide back to the calling shell.
55
   */
56
  public static void terminate( final int exitCode ) {
57
    System.exit( exitCode );
58
  }
59
60
  private static void parse( final String[] args ) {
61
    assert args != null;
62
63
    final var arguments = new Arguments( new Launcher( args ) );
64
    final var parser = new CommandLine( arguments );
65
66
    parser.setColorScheme( ColourScheme.create() );
67
68
    final var exitCode = parser.execute( args );
69
    final var parseResult = parser.getParseResult();
70
71
    if( parseResult.isUsageHelpRequested() ) {
72
      terminate( exitCode );
73
    }
74
    else if( parseResult.isVersionHelpRequested() ) {
75
      showAppInfo();
76
      terminate( exitCode );
7877
    }
7978
  }
...
183182
      log( t );
184183
    }
184
  }
185
186
  /**
187
   * Suppress writing to standard error, suppresses writing log messages.
188
   */
189
  private static void disableLogging() {
190
    LogManager.getLogManager().reset();
191
    System.err.close();
185192
  }
186193
194
  private static void showAppInfo() {
195
    out( "%n%s version %s", APP_TITLE, APP_VERSION );
196
    out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR );
197
    out( "Portions copyright 2015-2020 Karl Tauber.%n" );
198
  }
187199
}
188200
M src/main/java/com/keenwrite/MainPane.java
55
import com.keenwrite.editors.TextEditor;
66
import com.keenwrite.editors.TextResource;
7
import com.keenwrite.editors.definition.DefinitionEditor;
8
import com.keenwrite.editors.definition.TreeTransformer;
9
import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
10
import com.keenwrite.editors.markdown.MarkdownEditor;
11
import com.keenwrite.events.*;
12
import com.keenwrite.io.MediaType;
13
import com.keenwrite.preferences.Key;
14
import com.keenwrite.preferences.Workspace;
15
import com.keenwrite.preview.HtmlPreview;
16
import com.keenwrite.processors.HtmlPreviewProcessor;
17
import com.keenwrite.processors.Processor;
18
import com.keenwrite.processors.ProcessorContext;
19
import com.keenwrite.processors.ProcessorFactory;
20
import com.keenwrite.processors.r.InlineRProcessor;
21
import com.keenwrite.service.events.Notifier;
22
import com.keenwrite.sigils.PropertyKeyOperator;
23
import com.keenwrite.sigils.RKeyOperator;
24
import com.keenwrite.ui.explorer.FilePickerFactory;
25
import com.keenwrite.ui.heuristics.DocumentStatistics;
26
import com.keenwrite.ui.outline.DocumentOutline;
27
import com.keenwrite.util.GenericBuilder;
28
import com.panemu.tiwulfx.control.dock.DetachableTab;
29
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
30
import javafx.application.Platform;
31
import javafx.beans.property.*;
32
import javafx.collections.ListChangeListener;
33
import javafx.concurrent.Task;
34
import javafx.event.ActionEvent;
35
import javafx.event.Event;
36
import javafx.event.EventHandler;
37
import javafx.scene.Node;
38
import javafx.scene.Scene;
39
import javafx.scene.control.*;
40
import javafx.scene.control.TreeItem.TreeModificationEvent;
41
import javafx.scene.input.KeyEvent;
42
import javafx.scene.layout.FlowPane;
43
import javafx.stage.Stage;
44
import javafx.stage.Window;
45
import org.greenrobot.eventbus.Subscribe;
46
47
import java.io.File;
48
import java.io.FileNotFoundException;
49
import java.nio.file.Path;
50
import java.util.*;
51
import java.util.concurrent.ExecutorService;
52
import java.util.concurrent.ScheduledExecutorService;
53
import java.util.concurrent.ScheduledFuture;
54
import java.util.concurrent.atomic.AtomicBoolean;
55
import java.util.concurrent.atomic.AtomicReference;
56
import java.util.function.Function;
57
import java.util.function.UnaryOperator;
58
import java.util.stream.Collectors;
59
60
import static com.keenwrite.ExportFormat.NONE;
61
import static com.keenwrite.Messages.get;
62
import static com.keenwrite.constants.Constants.*;
63
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
64
import static com.keenwrite.events.Bus.register;
65
import static com.keenwrite.events.StatusEvent.clue;
66
import static com.keenwrite.io.MediaType.*;
67
import static com.keenwrite.preferences.AppKeys.*;
68
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
69
import static com.keenwrite.processors.ProcessorContext.Mutator;
70
import static com.keenwrite.processors.ProcessorContext.builder;
71
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
72
import static java.lang.String.format;
73
import static java.lang.System.getProperty;
74
import static java.util.concurrent.Executors.newFixedThreadPool;
75
import static java.util.concurrent.Executors.newScheduledThreadPool;
76
import static java.util.concurrent.TimeUnit.SECONDS;
77
import static java.util.stream.Collectors.groupingBy;
78
import static javafx.application.Platform.runLater;
79
import static javafx.scene.control.Alert.AlertType.ERROR;
80
import static javafx.scene.control.ButtonType.*;
81
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
82
import static javafx.scene.input.KeyCode.SPACE;
83
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
84
import static javafx.util.Duration.millis;
85
import static javax.swing.SwingUtilities.invokeLater;
86
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
87
88
/**
89
 * Responsible for wiring together the main application components for a
90
 * particular {@link Workspace} (project). These include the definition views,
91
 * text editors, and preview pane along with any corresponding controllers.
92
 */
93
public final class MainPane extends SplitPane {
94
95
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
96
  private static final Notifier sNotifier = Services.load( Notifier.class );
97
98
  /**
99
   * Used when opening files to determine how each file should be binned and
100
   * therefore what tab pane to be opened within.
101
   */
102
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
103
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
104
  );
105
106
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
107
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
108
    new AtomicReference<>();
109
110
  /**
111
   * Prevents re-instantiation of processing classes.
112
   */
113
  private final Map<TextResource, Processor<String>> mProcessors =
114
    new HashMap<>();
115
116
  private final Workspace mWorkspace;
117
118
  /**
119
   * Groups similar file type tabs together.
120
   */
121
  private final List<TabPane> mTabPanes = new ArrayList<>();
122
123
  /**
124
   * Renders the actively selected plain text editor tab.
125
   */
126
  private final HtmlPreview mPreview;
127
128
  /**
129
   * Provides an interactive document outline.
130
   */
131
  private final DocumentOutline mOutline = new DocumentOutline();
132
133
  /**
134
   * Changing the active editor fires the value changed event. This allows
135
   * refreshes to happen when external definitions are modified and need to
136
   * trigger the processing chain.
137
   */
138
  private final ObjectProperty<TextEditor> mTextEditor =
139
    createActiveTextEditor();
140
141
  /**
142
   * Changing the active definition editor fires the value changed event. This
143
   * allows refreshes to happen when external definitions are modified and need
144
   * to trigger the processing chain.
145
   */
146
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
147
148
  /**
149
   * Called when the definition data is changed.
150
   */
151
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
152
    event -> {
153
      process( getTextEditor() );
154
      save( getTextDefinition() );
155
    };
156
157
  /**
158
   * Tracks the number of detached tab panels opened into their own windows,
159
   * which allows unique identification of subordinate windows by their title.
160
   * It is doubtful more than 128 windows, much less 256, will be created.
161
   */
162
  private byte mWindowCount;
163
164
  private final DocumentStatistics mStatistics;
165
166
  /**
167
   * Adds all content panels to the main user interface. This will load the
168
   * configuration settings from the workspace to reproduce the settings from
169
   * a previous session.
170
   */
171
  public MainPane( final Workspace workspace ) {
172
    mWorkspace = workspace;
173
    mPreview = new HtmlPreview( workspace );
174
    mStatistics = new DocumentStatistics( workspace );
175
    mTextEditor.set( new MarkdownEditor( workspace ) );
176
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
177
178
    open( collect( getRecentFiles() ) );
179
    viewPreview();
180
    setDividerPositions( calculateDividerPositions() );
181
182
    // Once the main scene's window regains focus, update the active definition
183
    // editor to the currently selected tab.
184
    runLater( () -> getWindow().setOnCloseRequest( event -> {
185
      // Order matters: Open file names must be persisted before closing all.
186
      mWorkspace.save();
187
188
      if( closeAll() ) {
189
        Platform.exit();
190
        System.exit( 0 );
191
      }
192
193
      event.consume();
194
    } ) );
195
196
    register( this );
197
    initAutosave( workspace );
198
  }
199
200
  @Subscribe
201
  public void handle( final TextEditorFocusEvent event ) {
202
    mTextEditor.set( event.get() );
203
  }
204
205
  @Subscribe
206
  public void handle( final TextDefinitionFocusEvent event ) {
207
    mDefinitionEditor.set( event.get() );
208
  }
209
210
  /**
211
   * Typically called when a file name is clicked in the preview panel.
212
   *
213
   * @param event The event to process, must contain a valid file reference.
214
   */
215
  @Subscribe
216
  public void handle( final FileOpenEvent event ) {
217
    final File eventFile;
218
    final var eventUri = event.getUri();
219
220
    if( eventUri.isAbsolute() ) {
221
      eventFile = new File( eventUri.getPath() );
222
    }
223
    else {
224
      final var activeFile = getTextEditor().getFile();
225
      final var parent = activeFile.getParentFile();
226
227
      if( parent == null ) {
228
        clue( new FileNotFoundException( eventUri.getPath() ) );
229
        return;
230
      }
231
      else {
232
        final var parentPath = parent.getAbsolutePath();
233
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
234
      }
235
    }
236
237
    runLater( () -> open( eventFile ) );
238
  }
239
240
  @Subscribe
241
  public void handle( final CaretNavigationEvent event ) {
242
    runLater( () -> {
243
      final var textArea = getTextEditor().getTextArea();
244
      textArea.moveTo( event.getOffset() );
245
      textArea.requestFollowCaret();
246
      textArea.requestFocus();
247
    } );
248
  }
249
250
  @Subscribe
251
  @SuppressWarnings( "unused" )
252
  public void handle( final ExportFailedEvent event ) {
253
    final var os = getProperty( "os.name" );
254
    final var arch = getProperty( "os.arch" ).toLowerCase();
255
    final var bits = getProperty( "sun.arch.data.model" );
256
257
    final var title = Messages.get( "Alert.typesetter.missing.title" );
258
    final var header = Messages.get( "Alert.typesetter.missing.header" );
259
    final var version = Messages.get(
260
      "Alert.typesetter.missing.version",
261
      os,
262
      arch
263
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
264
        .replaceAll( "mips.*", "MIPS" )
265
        .replaceAll( "armv.*", "ARM" ),
266
      bits );
267
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
268
269
    // Download and install ConTeXt for {0} {1} {2}-bit
270
    final var content = format( "%s %s", text, version );
271
    final var flowPane = new FlowPane();
272
    final var link = new Hyperlink( text );
273
    final var label = new Label( version );
274
    flowPane.getChildren().addAll( link, label );
275
276
    final var alert = new Alert( ERROR, content, OK );
277
    alert.setTitle( title );
278
    alert.setHeaderText( header );
279
    alert.getDialogPane().contentProperty().set( flowPane );
280
    alert.setGraphic( ICON_DIALOG_NODE );
281
282
    link.setOnAction( ( e ) -> {
283
      alert.close();
284
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
285
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
286
    } );
287
288
    alert.showAndWait();
289
  }
290
291
  private void initAutosave( final Workspace workspace ) {
292
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
293
294
    rate.addListener(
295
      ( c, o, n ) -> {
296
        final var taskRef = mSaveTask.get();
297
298
        // Prevent multiple autosaves from running.
299
        if( taskRef != null ) {
300
          taskRef.cancel( false );
301
        }
302
303
        initAutosave( rate );
304
      }
305
    );
306
307
    // Start the save listener (avoids duplicating some code).
308
    initAutosave( rate );
309
  }
310
311
  private void initAutosave( final IntegerProperty rate ) {
312
    mSaveTask.set(
313
      mSaver.scheduleAtFixedRate(
314
        () -> {
315
          if( getTextEditor().isModified() ) {
316
            // Ensure the modified indicator is cleared by running on EDT.
317
            runLater( this::save );
318
          }
319
        }, 0, rate.intValue(), SECONDS
320
      )
321
    );
322
  }
323
324
  /**
325
   * TODO: Load divider positions from exported settings, see
326
   *   {@link #collect(SetProperty)} comment.
327
   */
328
  private double[] calculateDividerPositions() {
329
    final var ratio = 100f / getItems().size() / 100;
330
    final var positions = getDividerPositions();
331
332
    for( int i = 0; i < positions.length; i++ ) {
333
      positions[ i ] = ratio * i;
334
    }
335
336
    return positions;
337
  }
338
339
  /**
340
   * Opens all the files into the application, provided the paths are unique.
341
   * This may only be called for any type of files that a user can edit
342
   * (i.e., update and persist), such as definitions and text files.
343
   *
344
   * @param files The list of files to open.
345
   */
346
  public void open( final List<File> files ) {
347
    files.forEach( this::open );
348
  }
349
350
  /**
351
   * This opens the given file. Since the preview pane is not a file that
352
   * can be opened, it is safe to add a listener to the detachable pane.
353
   * This will exit early if the given file is not a regular file (i.e., a
354
   * directory).
355
   *
356
   * @param inputFile The file to open.
357
   */
358
  private void open( final File inputFile ) {
359
    // Prevent opening directories (a non-existent "untitled.md" is fine).
360
    if( !inputFile.isFile() && inputFile.exists() ) {
361
      return;
362
    }
363
364
    final var tab = createTab( inputFile );
365
    final var node = tab.getContent();
366
    final var mediaType = MediaType.valueFrom( inputFile );
367
    final var tabPane = obtainTabPane( mediaType );
368
369
    tab.setTooltip( createTooltip( inputFile ) );
370
    tabPane.setFocusTraversable( false );
371
    tabPane.setTabClosingPolicy( ALL_TABS );
372
    tabPane.getTabs().add( tab );
373
374
    // Attach the tab scene factory for new tab panes.
375
    if( !getItems().contains( tabPane ) ) {
376
      addTabPane(
377
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
378
      );
379
    }
380
381
    if( inputFile.isFile() ) {
382
      getRecentFiles().add( inputFile.getAbsolutePath() );
383
    }
384
  }
385
386
  /**
387
   * Opens a new text editor document using the default document file name.
388
   */
389
  public void newTextEditor() {
390
    open( DOCUMENT_DEFAULT );
391
  }
392
393
  /**
394
   * Opens a new definition editor document using the default definition
395
   * file name.
396
   */
397
  public void newDefinitionEditor() {
398
    open( DEFINITION_DEFAULT );
399
  }
400
401
  /**
402
   * Iterates over all tab panes to find all {@link TextEditor}s and request
403
   * that they save themselves.
404
   */
405
  public void saveAll() {
406
    mTabPanes.forEach(
407
      tp -> tp.getTabs().forEach( tab -> {
408
        final var node = tab.getContent();
409
410
        if( node instanceof final TextEditor editor ) {
411
          save( editor );
412
        }
413
      } )
414
    );
415
  }
416
417
  /**
418
   * Requests that the active {@link TextEditor} saves itself. Don't bother
419
   * checking if modified first because if the user swaps external media from
420
   * an external source (e.g., USB thumb drive), save should not second-guess
421
   * the user: save always re-saves. Also, it's less code.
422
   */
423
  public void save() {
424
    save( getTextEditor() );
425
  }
426
427
  /**
428
   * Saves the active {@link TextEditor} under a new name.
429
   *
430
   * @param files The new active editor {@link File} reference, must contain
431
   *              at least one element.
432
   */
433
  public void saveAs( final List<File> files ) {
434
    assert files != null;
435
    assert !files.isEmpty();
436
    final var editor = getTextEditor();
437
    final var tab = getTab( editor );
438
    final var file = files.get( 0 );
439
440
    editor.rename( file );
441
    tab.ifPresent( t -> {
442
      t.setText( editor.getFilename() );
443
      t.setTooltip( createTooltip( file ) );
444
    } );
445
446
    save();
447
  }
448
449
  /**
450
   * Saves the given {@link TextResource} to a file. This is typically used
451
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
452
   *
453
   * @param resource The resource to export.
454
   */
455
  private void save( final TextResource resource ) {
456
    try {
457
      resource.save();
458
    } catch( final Exception ex ) {
459
      clue( ex );
460
      sNotifier.alert(
461
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
462
      );
463
    }
464
  }
465
466
  /**
467
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
468
   *
469
   * @return {@code true} when all editors, modified or otherwise, were
470
   * permitted to close; {@code false} when one or more editors were modified
471
   * and the user requested no closing.
472
   */
473
  public boolean closeAll() {
474
    var closable = true;
475
476
    for( final var tabPane : mTabPanes ) {
477
      final var tabIterator = tabPane.getTabs().iterator();
478
479
      while( tabIterator.hasNext() ) {
480
        final var tab = tabIterator.next();
481
        final var resource = tab.getContent();
482
483
        // The definition panes auto-save, so being specific here prevents
484
        // closing the definitions in the situation where the user wants to
485
        // continue editing (i.e., possibly save unsaved work).
486
        if( !(resource instanceof TextEditor) ) {
487
          continue;
488
        }
489
490
        if( canClose( (TextEditor) resource ) ) {
491
          tabIterator.remove();
492
          close( tab );
493
        }
494
        else {
495
          closable = false;
496
        }
497
      }
498
    }
499
500
    return closable;
501
  }
502
503
  /**
504
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
505
   * event.
506
   *
507
   * @param tab The {@link Tab} that was closed.
508
   */
509
  private void close( final Tab tab ) {
510
    assert tab != null;
511
512
    final var handler = tab.getOnClosed();
513
514
    if( handler != null ) {
515
      handler.handle( new ActionEvent() );
516
    }
517
  }
518
519
  /**
520
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
521
   */
522
  public void close() {
523
    final var editor = getTextEditor();
524
525
    if( canClose( editor ) ) {
526
      close( editor );
527
    }
528
  }
529
530
  /**
531
   * Closes the given {@link TextResource}. This must not be called from within
532
   * a loop that iterates over the tab panes using {@code forEach}, lest a
533
   * concurrent modification exception be thrown.
534
   *
535
   * @param resource The {@link TextResource} to close, without confirming with
536
   *                 the user.
537
   */
538
  private void close( final TextResource resource ) {
539
    getTab( resource ).ifPresent(
540
      ( tab ) -> {
541
        close( tab );
542
        tab.getTabPane().getTabs().remove( tab );
543
      }
544
    );
545
  }
546
547
  /**
548
   * Answers whether the given {@link TextResource} may be closed.
549
   *
550
   * @param editor The {@link TextResource} to try closing.
551
   * @return {@code true} when the editor may be closed; {@code false} when
552
   * the user has requested to keep the editor open.
553
   */
554
  private boolean canClose( final TextResource editor ) {
555
    final var editorTab = getTab( editor );
556
    final var canClose = new AtomicBoolean( true );
557
558
    if( editor.isModified() ) {
559
      final var filename = new StringBuilder();
560
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
561
562
      final var message = sNotifier.createNotification(
563
        Messages.get( "Alert.file.close.title" ),
564
        Messages.get( "Alert.file.close.text" ),
565
        filename.toString()
566
      );
567
568
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
569
570
      dialog.showAndWait().ifPresent(
571
        save -> canClose.set( save == YES ? editor.save() : save == NO )
572
      );
573
    }
574
575
    return canClose.get();
576
  }
577
578
  private ObjectProperty<TextEditor> createActiveTextEditor() {
579
    final var editor = new SimpleObjectProperty<TextEditor>();
580
581
    editor.addListener( ( c, o, n ) -> {
582
      if( n != null ) {
583
        mPreview.setBaseUri( n.getPath() );
584
        process( n );
585
      }
586
    } );
587
588
    return editor;
589
  }
590
591
  /**
592
   * Adds the HTML preview tab to its own, singular tab pane.
593
   */
594
  public void viewPreview() {
595
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
596
  }
597
598
  /**
599
   * Adds the document outline tab to its own, singular tab pane.
600
   */
601
  public void viewOutline() {
602
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
603
  }
604
605
  public void viewStatistics() {
606
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
607
  }
608
609
  public void viewFiles() {
610
    try {
611
      final var factory = new FilePickerFactory( getWorkspace() );
612
      final var fileManager = factory.createModeless();
613
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
614
    } catch( final Exception ex ) {
615
      clue( ex );
616
    }
617
  }
618
619
  private void viewTab(
620
    final Node node, final MediaType mediaType, final String key ) {
621
    final var tabPane = obtainTabPane( mediaType );
622
623
    for( final var tab : tabPane.getTabs() ) {
624
      if( tab.getContent() == node ) {
625
        return;
626
      }
627
    }
628
629
    tabPane.getTabs().add( createTab( get( key ), node ) );
630
    addTabPane( tabPane );
631
  }
632
633
  public void viewRefresh() {
634
    mPreview.refresh();
635
  }
636
637
  /**
638
   * Returns the tab that contains the given {@link TextEditor}.
639
   *
640
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
641
   * @return The first tab having content that matches the given tab.
642
   */
643
  private Optional<Tab> getTab( final TextResource editor ) {
644
    return mTabPanes.stream()
645
                    .flatMap( pane -> pane.getTabs().stream() )
646
                    .filter( tab -> editor.equals( tab.getContent() ) )
647
                    .findFirst();
648
  }
649
650
  /**
651
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
652
   * is used to detect when the active {@link DefinitionEditor} has changed.
653
   * Upon changing, the variables are interpolated and the active text editor
654
   * is refreshed.
655
   *
656
   * @param textEditor Text editor to update with the revised resolved map.
657
   * @return A newly configured property that represents the active
658
   * {@link DefinitionEditor}, never null.
659
   */
660
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
661
    final ObjectProperty<TextEditor> textEditor ) {
662
    final var defEditor = new SimpleObjectProperty<>(
663
      createDefinitionEditor()
664
    );
665
666
    defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
667
668
    return defEditor;
669
  }
670
671
  private Tab createTab( final String filename, final Node node ) {
672
    return new DetachableTab( filename, node );
673
  }
674
675
  private Tab createTab( final File file ) {
676
    final var r = createTextResource( file );
677
    final var tab = createTab( r.getFilename(), r.getNode() );
678
679
    r.modifiedProperty().addListener(
680
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
681
    );
682
683
    // This is called when either the tab is closed by the user clicking on
684
    // the tab's close icon or when closing (all) from the file menu.
685
    tab.setOnClosed(
686
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
687
    );
688
689
    // When closing a tab, give focus to the newly revealed tab.
690
    tab.selectedProperty().addListener( ( c, o, n ) -> {
691
      if( n != null && n ) {
692
        final var pane = tab.getTabPane();
693
694
        if( pane != null ) {
695
          pane.requestFocus();
696
        }
697
      }
698
    } );
699
700
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
701
      if( nPane != null ) {
702
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
703
          if( n != null && n ) {
704
            final var selected = nPane.getSelectionModel().getSelectedItem();
705
            final var node = selected.getContent();
706
            node.requestFocus();
707
          }
708
        } );
709
      }
710
    } );
711
712
    return tab;
713
  }
714
715
  /**
716
   * Creates bins for the different {@link MediaType}s, which eventually are
717
   * added to the UI as separate tab panes. If ever a general-purpose scene
718
   * exporter is developed to serialize a scene to an FXML file, this could
719
   * be replaced by such a class.
720
   * <p>
721
   * When binning the files, this makes sure that at least one file exists
722
   * for every type. If the user has opted to close a particular type (such
723
   * as the definition pane), the view will suppressed elsewhere.
724
   * </p>
725
   * <p>
726
   * The order that the binned files are returned will be reflected in the
727
   * order that the corresponding panes are rendered in the UI.
728
   * </p>
729
   *
730
   * @param paths The file paths to bin according to their type.
731
   * @return An in-order list of files, first by structured definition files,
732
   * then by plain text documents.
733
   */
734
  private List<File> collect( final SetProperty<String> paths ) {
735
    // Treat all files destined for the text editor as plain text documents
736
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
737
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
738
    final Function<MediaType, MediaType> bin =
739
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
740
741
    // Create two groups: YAML files and plain text files. The order that
742
    // the elements are listed in the enumeration for media types determines
743
    // what files are loaded first. Variable definitions come before all other
744
    // plain text documents.
745
    final var bins = paths
746
      .stream()
747
      .collect(
748
        groupingBy(
749
          path -> bin.apply( MediaType.fromFilename( path ) ),
750
          () -> new TreeMap<>( Enum::compareTo ),
751
          Collectors.toList()
752
        )
753
      );
754
755
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
756
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
757
758
    final var result = new LinkedList<File>();
759
760
    // Ensure that the same types are listed together (keep insertion order).
761
    bins.forEach( ( mediaType, files ) -> result.addAll(
762
      files.stream().map( File::new ).toList() )
763
    );
764
765
    return result;
766
  }
767
768
  /**
769
   * Force the active editor to update, which will cause the processor
770
   * to re-evaluate the interpolated definition map thereby updating the
771
   * preview pane.
772
   *
773
   * @param editor Contains the source document to update in the preview pane.
774
   */
775
  private void process( final TextEditor editor ) {
776
    // Ensure processing does not run on the JavaFX thread, which frees the
777
    // text editor immediately for caret movement. The preview will have a
778
    // slight delay when catching up to the caret position.
779
    final var task = new Task<Void>() {
780
      @Override
781
      public Void call() {
782
        try {
783
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
784
          p.apply( editor == null ? "" : editor.getText() );
785
        } catch( final Exception ex ) {
786
          clue( ex );
787
        }
788
789
        return null;
790
      }
791
    };
792
793
    // TODO: Each time the editor successfully runs the processor the task is
794
    //   considered successful. Due to the rapid-fire nature of processing
795
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
796
    //   scroll each time.
797
    //   The algorithm:
798
    //   1. Peek at the oldest time.
799
    //   2. If the difference between the oldest time and current time exceeds
800
    //      250 milliseconds, then invoke the scrolling.
801
    //   3. Insert the current time into the circular queue.
802
    task.setOnSucceeded(
803
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
804
    );
805
806
    // Prevents multiple process requests from executing simultaneously (due
807
    // to having a restricted queue size).
808
    sExecutor.execute( task );
809
  }
810
811
  /**
812
   * Lazily creates a {@link TabPane} configured to listen for tab select
813
   * events. The tab pane is associated with a given media type so that
814
   * similar files can be grouped together.
815
   *
816
   * @param mediaType The media type to associate with the tab pane.
817
   * @return An instance of {@link TabPane} that will handle tab docking.
818
   */
819
  private TabPane obtainTabPane( final MediaType mediaType ) {
820
    for( final var pane : mTabPanes ) {
821
      for( final var tab : pane.getTabs() ) {
822
        final var node = tab.getContent();
823
824
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
825
          return pane;
826
        }
827
      }
828
    }
829
830
    final var pane = createTabPane();
831
    mTabPanes.add( pane );
832
    return pane;
833
  }
834
835
  /**
836
   * Creates an initialized {@link TabPane} instance.
837
   *
838
   * @return A new {@link TabPane} with all listeners configured.
839
   */
840
  private TabPane createTabPane() {
841
    final var tabPane = new DetachableTabPane();
842
843
    initStageOwnerFactory( tabPane );
844
    initTabListener( tabPane );
845
846
    return tabPane;
847
  }
848
849
  /**
850
   * When any {@link DetachableTabPane} is detached from the main window,
851
   * the stage owner factory must be given its parent window, which will
852
   * own the child window. The parent window is the {@link MainPane}'s
853
   * {@link Scene}'s {@link Window} instance.
854
   *
855
   * <p>
856
   * This will derives the new title from the main window title, incrementing
857
   * the window count to help uniquely identify the child windows.
858
   * </p>
859
   *
860
   * @param tabPane A new {@link DetachableTabPane} to configure.
861
   */
862
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
863
    tabPane.setStageOwnerFactory( ( stage ) -> {
864
      final var title = get(
865
        "Detach.tab.title",
866
        ((Stage) getWindow()).getTitle(), ++mWindowCount
867
      );
868
      stage.setTitle( title );
869
870
      return getScene().getWindow();
871
    } );
872
  }
873
874
  /**
875
   * Responsible for configuring the content of each {@link DetachableTab} when
876
   * it is added to the given {@link DetachableTabPane} instance.
877
   * <p>
878
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
879
   * is initialized to perform synchronized scrolling between the editor and
880
   * its preview window. Additionally, the last tab in the tab pane's list of
881
   * tabs is given focus.
882
   * </p>
883
   * <p>
884
   * Note that multiple tabs can be added simultaneously.
885
   * </p>
886
   *
887
   * @param tabPane A new {@link TabPane} to configure.
888
   */
889
  private void initTabListener( final TabPane tabPane ) {
890
    tabPane.getTabs().addListener(
891
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
892
        while( listener.next() ) {
893
          if( listener.wasAdded() ) {
894
            final var tabs = listener.getAddedSubList();
895
896
            tabs.forEach( tab -> {
897
              final var node = tab.getContent();
898
899
              if( node instanceof TextEditor ) {
900
                initScrollEventListener( tab );
901
              }
902
            } );
903
904
            // Select and give focus to the last tab opened.
905
            final var index = tabs.size() - 1;
906
            if( index >= 0 ) {
907
              final var tab = tabs.get( index );
908
              tabPane.getSelectionModel().select( tab );
909
              tab.getContent().requestFocus();
910
            }
911
          }
912
        }
913
      }
914
    );
915
  }
916
917
  /**
918
   * Synchronizes scrollbar positions between the given {@link Tab} that
919
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
920
   *
921
   * @param tab The container for an instance of {@link TextEditor}.
922
   */
923
  private void initScrollEventListener( final Tab tab ) {
924
    final var editor = (TextEditor) tab.getContent();
925
    final var scrollPane = editor.getScrollPane();
926
    final var scrollBar = mPreview.getVerticalScrollBar();
927
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
928
929
    handler.enabledProperty().bind( tab.selectedProperty() );
930
  }
931
932
  private void addTabPane( final int index, final TabPane tabPane ) {
933
    final var items = getItems();
934
935
    if( !items.contains( tabPane ) ) {
936
      items.add( index, tabPane );
937
    }
938
  }
939
940
  private void addTabPane( final TabPane tabPane ) {
941
    addTabPane( getItems().size(), tabPane );
942
  }
943
944
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
945
    return builder()
946
      .with( Mutator::setDefinitions, this::getDefinitions )
947
      .with( Mutator::setWorkspace, mWorkspace )
948
      .with( Mutator::setCaret, () -> getTextEditor().getCaret() );
949
  }
950
951
  public ProcessorContext createProcessorContext() {
952
    return createProcessorContext( null, NONE );
953
  }
954
955
  /**
956
   * @param outputPath Used when exporting to a PDF file (binary).
957
   * @param format     Used when processors export to a new text format.
958
   * @return A new {@link ProcessorContext} to use when creating an instance of
959
   * {@link Processor}.
960
   */
961
  public ProcessorContext createProcessorContext(
962
    final Path outputPath, final ExportFormat format ) {
963
    final var textEditor = getTextEditor();
964
    final var inputPath = textEditor.getPath();
965
966
    return createProcessorContextBuilder()
967
      .with( Mutator::setInputPath, inputPath )
968
      .with( Mutator::setOutputPath, outputPath )
969
      .with( Mutator::setExportFormat, format )
970
      .build();
971
  }
972
973
  /**
974
   * @param inputPath Used by {@link ProcessorFactory} to determine
975
   *                  {@link Processor} type to create based on file type.
976
   * @return A new {@link ProcessorContext} to use when creating an instance of
977
   * {@link Processor}.
978
   */
979
  private ProcessorContext createProcessorContext( final Path inputPath ) {
980
    return createProcessorContextBuilder()
981
      .with( Mutator::setInputPath, inputPath )
982
      .with( Mutator::setExportFormat, NONE )
983
      .build();
984
  }
985
986
  private TextResource createTextResource( final File file ) {
987
    // TODO: Create PlainTextEditor that's returned by default.
988
    return MediaType.valueFrom( file ) == TEXT_YAML
989
      ? createDefinitionEditor( file )
990
      : createMarkdownEditor( file );
991
  }
992
993
  /**
994
   * Creates an instance of {@link MarkdownEditor} that listens for both
995
   * caret change events and text change events. Text change events must
996
   * take priority over caret change events because it's possible to change
997
   * the text without moving the caret (e.g., delete selected text).
998
   *
999
   * @param inputFile The file containing contents for the text editor.
1000
   * @return A non-null text editor.
1001
   */
1002
  private TextResource createMarkdownEditor( final File inputFile ) {
1003
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1004
1005
    mProcessors.computeIfAbsent(
1006
      editor, p -> createProcessors(
1007
        createProcessorContext( inputFile.toPath() ),
1008
        createHtmlPreviewProcessor()
1009
      )
1010
    );
1011
1012
    // Listener for editor modifications or caret position changes.
1013
    editor.addDirtyListener( ( c, o, n ) -> {
1014
      if( n ) {
1015
        // Reset the status bar after changing the text.
1016
        clue();
1017
1018
        // Processing the text may update the status bar.
1019
        process( getTextEditor() );
7
import com.keenwrite.editors.common.ScrollEventHandler;
8
import com.keenwrite.editors.common.VariableNameInjector;
9
import com.keenwrite.editors.definition.DefinitionEditor;
10
import com.keenwrite.editors.definition.TreeTransformer;
11
import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
12
import com.keenwrite.editors.markdown.MarkdownEditor;
13
import com.keenwrite.events.*;
14
import com.keenwrite.io.MediaType;
15
import com.keenwrite.preferences.Key;
16
import com.keenwrite.preferences.Workspace;
17
import com.keenwrite.preview.HtmlPreview;
18
import com.keenwrite.processors.HtmlPreviewProcessor;
19
import com.keenwrite.processors.Processor;
20
import com.keenwrite.processors.ProcessorContext;
21
import com.keenwrite.processors.ProcessorFactory;
22
import com.keenwrite.processors.r.InlineRProcessor;
23
import com.keenwrite.service.events.Notifier;
24
import com.keenwrite.sigils.PropertyKeyOperator;
25
import com.keenwrite.sigils.RKeyOperator;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.heuristics.DocumentStatistics;
28
import com.keenwrite.ui.outline.DocumentOutline;
29
import com.keenwrite.util.GenericBuilder;
30
import com.panemu.tiwulfx.control.dock.DetachableTab;
31
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
32
import javafx.application.Platform;
33
import javafx.beans.property.*;
34
import javafx.collections.ListChangeListener;
35
import javafx.concurrent.Task;
36
import javafx.event.ActionEvent;
37
import javafx.event.Event;
38
import javafx.event.EventHandler;
39
import javafx.scene.Node;
40
import javafx.scene.Scene;
41
import javafx.scene.control.*;
42
import javafx.scene.control.TreeItem.TreeModificationEvent;
43
import javafx.scene.input.KeyEvent;
44
import javafx.scene.layout.FlowPane;
45
import javafx.stage.Stage;
46
import javafx.stage.Window;
47
import org.greenrobot.eventbus.Subscribe;
48
49
import java.io.File;
50
import java.io.FileNotFoundException;
51
import java.nio.file.Path;
52
import java.util.*;
53
import java.util.concurrent.ExecutorService;
54
import java.util.concurrent.ScheduledExecutorService;
55
import java.util.concurrent.ScheduledFuture;
56
import java.util.concurrent.atomic.AtomicBoolean;
57
import java.util.concurrent.atomic.AtomicReference;
58
import java.util.function.Function;
59
import java.util.function.UnaryOperator;
60
import java.util.stream.Collectors;
61
62
import static com.keenwrite.ExportFormat.NONE;
63
import static com.keenwrite.Launcher.terminate;
64
import static com.keenwrite.Messages.get;
65
import static com.keenwrite.constants.Constants.*;
66
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
67
import static com.keenwrite.events.Bus.register;
68
import static com.keenwrite.events.StatusEvent.clue;
69
import static com.keenwrite.io.MediaType.*;
70
import static com.keenwrite.preferences.AppKeys.*;
71
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
72
import static com.keenwrite.processors.ProcessorContext.Mutator;
73
import static com.keenwrite.processors.ProcessorContext.builder;
74
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
75
import static java.lang.String.format;
76
import static java.lang.System.getProperty;
77
import static java.util.concurrent.Executors.newFixedThreadPool;
78
import static java.util.concurrent.Executors.newScheduledThreadPool;
79
import static java.util.concurrent.TimeUnit.SECONDS;
80
import static java.util.stream.Collectors.groupingBy;
81
import static javafx.application.Platform.runLater;
82
import static javafx.scene.control.Alert.AlertType.ERROR;
83
import static javafx.scene.control.ButtonType.*;
84
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
85
import static javafx.scene.input.KeyCode.SPACE;
86
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
87
import static javafx.util.Duration.millis;
88
import static javax.swing.SwingUtilities.invokeLater;
89
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
90
91
/**
92
 * Responsible for wiring together the main application components for a
93
 * particular {@link Workspace} (project). These include the definition views,
94
 * text editors, and preview pane along with any corresponding controllers.
95
 */
96
public final class MainPane extends SplitPane {
97
98
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
99
  private static final Notifier sNotifier = Services.load( Notifier.class );
100
101
  /**
102
   * Used when opening files to determine how each file should be binned and
103
   * therefore what tab pane to be opened within.
104
   */
105
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
106
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
107
  );
108
109
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
110
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
111
    new AtomicReference<>();
112
113
  /**
114
   * Prevents re-instantiation of processing classes.
115
   */
116
  private final Map<TextResource, Processor<String>> mProcessors =
117
    new HashMap<>();
118
119
  private final Workspace mWorkspace;
120
121
  /**
122
   * Groups similar file type tabs together.
123
   */
124
  private final List<TabPane> mTabPanes = new ArrayList<>();
125
126
  /**
127
   * Renders the actively selected plain text editor tab.
128
   */
129
  private final HtmlPreview mPreview;
130
131
  /**
132
   * Provides an interactive document outline.
133
   */
134
  private final DocumentOutline mOutline = new DocumentOutline();
135
136
  /**
137
   * Changing the active editor fires the value changed event. This allows
138
   * refreshes to happen when external definitions are modified and need to
139
   * trigger the processing chain.
140
   */
141
  private final ObjectProperty<TextEditor> mTextEditor =
142
    createActiveTextEditor();
143
144
  /**
145
   * Changing the active definition editor fires the value changed event. This
146
   * allows refreshes to happen when external definitions are modified and need
147
   * to trigger the processing chain.
148
   */
149
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
150
151
  /**
152
   * Called when the definition data is changed.
153
   */
154
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
155
    event -> {
156
      process( getTextEditor() );
157
      save( getTextDefinition() );
158
    };
159
160
  /**
161
   * Tracks the number of detached tab panels opened into their own windows,
162
   * which allows unique identification of subordinate windows by their title.
163
   * It is doubtful more than 128 windows, much less 256, will be created.
164
   */
165
  private byte mWindowCount;
166
167
  private final DocumentStatistics mStatistics;
168
169
  /**
170
   * Adds all content panels to the main user interface. This will load the
171
   * configuration settings from the workspace to reproduce the settings from
172
   * a previous session.
173
   */
174
  public MainPane( final Workspace workspace ) {
175
    mWorkspace = workspace;
176
    mPreview = new HtmlPreview( workspace );
177
    mStatistics = new DocumentStatistics( workspace );
178
    mTextEditor.set( new MarkdownEditor( workspace ) );
179
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
180
181
    open( collect( getRecentFiles() ) );
182
    viewPreview();
183
    setDividerPositions( calculateDividerPositions() );
184
185
    // Once the main scene's window regains focus, update the active definition
186
    // editor to the currently selected tab.
187
    runLater( () -> getWindow().setOnCloseRequest( event -> {
188
      // Order matters: Open file names must be persisted before closing all.
189
      mWorkspace.save();
190
191
      if( closeAll() ) {
192
        Platform.exit();
193
        terminate( 0 );
194
      }
195
196
      event.consume();
197
    } ) );
198
199
    register( this );
200
    initAutosave( workspace );
201
  }
202
203
  @Subscribe
204
  public void handle( final TextEditorFocusEvent event ) {
205
    mTextEditor.set( event.get() );
206
  }
207
208
  @Subscribe
209
  public void handle( final TextDefinitionFocusEvent event ) {
210
    mDefinitionEditor.set( event.get() );
211
  }
212
213
  /**
214
   * Typically called when a file name is clicked in the preview panel.
215
   *
216
   * @param event The event to process, must contain a valid file reference.
217
   */
218
  @Subscribe
219
  public void handle( final FileOpenEvent event ) {
220
    final File eventFile;
221
    final var eventUri = event.getUri();
222
223
    if( eventUri.isAbsolute() ) {
224
      eventFile = new File( eventUri.getPath() );
225
    }
226
    else {
227
      final var activeFile = getTextEditor().getFile();
228
      final var parent = activeFile.getParentFile();
229
230
      if( parent == null ) {
231
        clue( new FileNotFoundException( eventUri.getPath() ) );
232
        return;
233
      }
234
      else {
235
        final var parentPath = parent.getAbsolutePath();
236
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
237
      }
238
    }
239
240
    runLater( () -> open( eventFile ) );
241
  }
242
243
  @Subscribe
244
  public void handle( final CaretNavigationEvent event ) {
245
    runLater( () -> {
246
      final var textArea = getTextEditor().getTextArea();
247
      textArea.moveTo( event.getOffset() );
248
      textArea.requestFollowCaret();
249
      textArea.requestFocus();
250
    } );
251
  }
252
253
  @Subscribe
254
  @SuppressWarnings( "unused" )
255
  public void handle( final ExportFailedEvent event ) {
256
    final var os = getProperty( "os.name" );
257
    final var arch = getProperty( "os.arch" ).toLowerCase();
258
    final var bits = getProperty( "sun.arch.data.model" );
259
260
    final var title = Messages.get( "Alert.typesetter.missing.title" );
261
    final var header = Messages.get( "Alert.typesetter.missing.header" );
262
    final var version = Messages.get(
263
      "Alert.typesetter.missing.version",
264
      os,
265
      arch
266
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
267
        .replaceAll( "mips.*", "MIPS" )
268
        .replaceAll( "armv.*", "ARM" ),
269
      bits );
270
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
271
272
    // Download and install ConTeXt for {0} {1} {2}-bit
273
    final var content = format( "%s %s", text, version );
274
    final var flowPane = new FlowPane();
275
    final var link = new Hyperlink( text );
276
    final var label = new Label( version );
277
    flowPane.getChildren().addAll( link, label );
278
279
    final var alert = new Alert( ERROR, content, OK );
280
    alert.setTitle( title );
281
    alert.setHeaderText( header );
282
    alert.getDialogPane().contentProperty().set( flowPane );
283
    alert.setGraphic( ICON_DIALOG_NODE );
284
285
    link.setOnAction( ( e ) -> {
286
      alert.close();
287
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
288
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
289
    } );
290
291
    alert.showAndWait();
292
  }
293
294
  private void initAutosave( final Workspace workspace ) {
295
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
296
297
    rate.addListener(
298
      ( c, o, n ) -> {
299
        final var taskRef = mSaveTask.get();
300
301
        // Prevent multiple autosaves from running.
302
        if( taskRef != null ) {
303
          taskRef.cancel( false );
304
        }
305
306
        initAutosave( rate );
307
      }
308
    );
309
310
    // Start the save listener (avoids duplicating some code).
311
    initAutosave( rate );
312
  }
313
314
  private void initAutosave( final IntegerProperty rate ) {
315
    mSaveTask.set(
316
      mSaver.scheduleAtFixedRate(
317
        () -> {
318
          if( getTextEditor().isModified() ) {
319
            // Ensure the modified indicator is cleared by running on EDT.
320
            runLater( this::save );
321
          }
322
        }, 0, rate.intValue(), SECONDS
323
      )
324
    );
325
  }
326
327
  /**
328
   * TODO: Load divider positions from exported settings, see
329
   *   {@link #collect(SetProperty)} comment.
330
   */
331
  private double[] calculateDividerPositions() {
332
    final var ratio = 100f / getItems().size() / 100;
333
    final var positions = getDividerPositions();
334
335
    for( int i = 0; i < positions.length; i++ ) {
336
      positions[ i ] = ratio * i;
337
    }
338
339
    return positions;
340
  }
341
342
  /**
343
   * Opens all the files into the application, provided the paths are unique.
344
   * This may only be called for any type of files that a user can edit
345
   * (i.e., update and persist), such as definitions and text files.
346
   *
347
   * @param files The list of files to open.
348
   */
349
  public void open( final List<File> files ) {
350
    files.forEach( this::open );
351
  }
352
353
  /**
354
   * This opens the given file. Since the preview pane is not a file that
355
   * can be opened, it is safe to add a listener to the detachable pane.
356
   * This will exit early if the given file is not a regular file (i.e., a
357
   * directory).
358
   *
359
   * @param inputFile The file to open.
360
   */
361
  private void open( final File inputFile ) {
362
    // Prevent opening directories (a non-existent "untitled.md" is fine).
363
    if( !inputFile.isFile() && inputFile.exists() ) {
364
      return;
365
    }
366
367
    final var tab = createTab( inputFile );
368
    final var node = tab.getContent();
369
    final var mediaType = MediaType.valueFrom( inputFile );
370
    final var tabPane = obtainTabPane( mediaType );
371
372
    tab.setTooltip( createTooltip( inputFile ) );
373
    tabPane.setFocusTraversable( false );
374
    tabPane.setTabClosingPolicy( ALL_TABS );
375
    tabPane.getTabs().add( tab );
376
377
    // Attach the tab scene factory for new tab panes.
378
    if( !getItems().contains( tabPane ) ) {
379
      addTabPane(
380
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
381
      );
382
    }
383
384
    if( inputFile.isFile() ) {
385
      getRecentFiles().add( inputFile.getAbsolutePath() );
386
    }
387
  }
388
389
  /**
390
   * Opens a new text editor document using the default document file name.
391
   */
392
  public void newTextEditor() {
393
    open( DOCUMENT_DEFAULT );
394
  }
395
396
  /**
397
   * Opens a new definition editor document using the default definition
398
   * file name.
399
   */
400
  public void newDefinitionEditor() {
401
    open( DEFINITION_DEFAULT );
402
  }
403
404
  /**
405
   * Iterates over all tab panes to find all {@link TextEditor}s and request
406
   * that they save themselves.
407
   */
408
  public void saveAll() {
409
    mTabPanes.forEach(
410
      tp -> tp.getTabs().forEach( tab -> {
411
        final var node = tab.getContent();
412
413
        if( node instanceof final TextEditor editor ) {
414
          save( editor );
415
        }
416
      } )
417
    );
418
  }
419
420
  /**
421
   * Requests that the active {@link TextEditor} saves itself. Don't bother
422
   * checking if modified first because if the user swaps external media from
423
   * an external source (e.g., USB thumb drive), save should not second-guess
424
   * the user: save always re-saves. Also, it's less code.
425
   */
426
  public void save() {
427
    save( getTextEditor() );
428
  }
429
430
  /**
431
   * Saves the active {@link TextEditor} under a new name.
432
   *
433
   * @param files The new active editor {@link File} reference, must contain
434
   *              at least one element.
435
   */
436
  public void saveAs( final List<File> files ) {
437
    assert files != null;
438
    assert !files.isEmpty();
439
    final var editor = getTextEditor();
440
    final var tab = getTab( editor );
441
    final var file = files.get( 0 );
442
443
    editor.rename( file );
444
    tab.ifPresent( t -> {
445
      t.setText( editor.getFilename() );
446
      t.setTooltip( createTooltip( file ) );
447
    } );
448
449
    save();
450
  }
451
452
  /**
453
   * Saves the given {@link TextResource} to a file. This is typically used
454
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
455
   *
456
   * @param resource The resource to export.
457
   */
458
  private void save( final TextResource resource ) {
459
    try {
460
      resource.save();
461
    } catch( final Exception ex ) {
462
      clue( ex );
463
      sNotifier.alert(
464
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
465
      );
466
    }
467
  }
468
469
  /**
470
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
471
   *
472
   * @return {@code true} when all editors, modified or otherwise, were
473
   * permitted to close; {@code false} when one or more editors were modified
474
   * and the user requested no closing.
475
   */
476
  public boolean closeAll() {
477
    var closable = true;
478
479
    for( final var tabPane : mTabPanes ) {
480
      final var tabIterator = tabPane.getTabs().iterator();
481
482
      while( tabIterator.hasNext() ) {
483
        final var tab = tabIterator.next();
484
        final var resource = tab.getContent();
485
486
        // The definition panes auto-save, so being specific here prevents
487
        // closing the definitions in the situation where the user wants to
488
        // continue editing (i.e., possibly save unsaved work).
489
        if( !(resource instanceof TextEditor) ) {
490
          continue;
491
        }
492
493
        if( canClose( (TextEditor) resource ) ) {
494
          tabIterator.remove();
495
          close( tab );
496
        }
497
        else {
498
          closable = false;
499
        }
500
      }
501
    }
502
503
    return closable;
504
  }
505
506
  /**
507
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
508
   * event.
509
   *
510
   * @param tab The {@link Tab} that was closed.
511
   */
512
  private void close( final Tab tab ) {
513
    assert tab != null;
514
515
    final var handler = tab.getOnClosed();
516
517
    if( handler != null ) {
518
      handler.handle( new ActionEvent() );
519
    }
520
  }
521
522
  /**
523
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
524
   */
525
  public void close() {
526
    final var editor = getTextEditor();
527
528
    if( canClose( editor ) ) {
529
      close( editor );
530
    }
531
  }
532
533
  /**
534
   * Closes the given {@link TextResource}. This must not be called from within
535
   * a loop that iterates over the tab panes using {@code forEach}, lest a
536
   * concurrent modification exception be thrown.
537
   *
538
   * @param resource The {@link TextResource} to close, without confirming with
539
   *                 the user.
540
   */
541
  private void close( final TextResource resource ) {
542
    getTab( resource ).ifPresent(
543
      ( tab ) -> {
544
        close( tab );
545
        tab.getTabPane().getTabs().remove( tab );
546
      }
547
    );
548
  }
549
550
  /**
551
   * Answers whether the given {@link TextResource} may be closed.
552
   *
553
   * @param editor The {@link TextResource} to try closing.
554
   * @return {@code true} when the editor may be closed; {@code false} when
555
   * the user has requested to keep the editor open.
556
   */
557
  private boolean canClose( final TextResource editor ) {
558
    final var editorTab = getTab( editor );
559
    final var canClose = new AtomicBoolean( true );
560
561
    if( editor.isModified() ) {
562
      final var filename = new StringBuilder();
563
      editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
564
565
      final var message = sNotifier.createNotification(
566
        Messages.get( "Alert.file.close.title" ),
567
        Messages.get( "Alert.file.close.text" ),
568
        filename.toString()
569
      );
570
571
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
572
573
      dialog.showAndWait().ifPresent(
574
        save -> canClose.set( save == YES ? editor.save() : save == NO )
575
      );
576
    }
577
578
    return canClose.get();
579
  }
580
581
  private ObjectProperty<TextEditor> createActiveTextEditor() {
582
    final var editor = new SimpleObjectProperty<TextEditor>();
583
584
    editor.addListener( ( c, o, n ) -> {
585
      if( n != null ) {
586
        mPreview.setBaseUri( n.getPath() );
587
        process( n );
588
      }
589
    } );
590
591
    return editor;
592
  }
593
594
  /**
595
   * Adds the HTML preview tab to its own, singular tab pane.
596
   */
597
  public void viewPreview() {
598
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
599
  }
600
601
  /**
602
   * Adds the document outline tab to its own, singular tab pane.
603
   */
604
  public void viewOutline() {
605
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
606
  }
607
608
  public void viewStatistics() {
609
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
610
  }
611
612
  public void viewFiles() {
613
    try {
614
      final var factory = new FilePickerFactory( getWorkspace() );
615
      final var fileManager = factory.createModeless();
616
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
617
    } catch( final Exception ex ) {
618
      clue( ex );
619
    }
620
  }
621
622
  private void viewTab(
623
    final Node node, final MediaType mediaType, final String key ) {
624
    final var tabPane = obtainTabPane( mediaType );
625
626
    for( final var tab : tabPane.getTabs() ) {
627
      if( tab.getContent() == node ) {
628
        return;
629
      }
630
    }
631
632
    tabPane.getTabs().add( createTab( get( key ), node ) );
633
    addTabPane( tabPane );
634
  }
635
636
  public void viewRefresh() {
637
    mPreview.refresh();
638
  }
639
640
  /**
641
   * Returns the tab that contains the given {@link TextEditor}.
642
   *
643
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
644
   * @return The first tab having content that matches the given tab.
645
   */
646
  private Optional<Tab> getTab( final TextResource editor ) {
647
    return mTabPanes.stream()
648
                    .flatMap( pane -> pane.getTabs().stream() )
649
                    .filter( tab -> editor.equals( tab.getContent() ) )
650
                    .findFirst();
651
  }
652
653
  /**
654
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
655
   * is used to detect when the active {@link DefinitionEditor} has changed.
656
   * Upon changing, the variables are interpolated and the active text editor
657
   * is refreshed.
658
   *
659
   * @param textEditor Text editor to update with the revised resolved map.
660
   * @return A newly configured property that represents the active
661
   * {@link DefinitionEditor}, never null.
662
   */
663
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
664
    final ObjectProperty<TextEditor> textEditor ) {
665
    final var defEditor = new SimpleObjectProperty<>(
666
      createDefinitionEditor()
667
    );
668
669
    defEditor.addListener( ( c, o, n ) -> process( textEditor.get() ) );
670
671
    return defEditor;
672
  }
673
674
  private Tab createTab( final String filename, final Node node ) {
675
    return new DetachableTab( filename, node );
676
  }
677
678
  private Tab createTab( final File file ) {
679
    final var r = createTextResource( file );
680
    final var tab = createTab( r.getFilename(), r.getNode() );
681
682
    r.modifiedProperty().addListener(
683
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
684
    );
685
686
    // This is called when either the tab is closed by the user clicking on
687
    // the tab's close icon or when closing (all) from the file menu.
688
    tab.setOnClosed(
689
      ( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
690
    );
691
692
    // When closing a tab, give focus to the newly revealed tab.
693
    tab.selectedProperty().addListener( ( c, o, n ) -> {
694
      if( n != null && n ) {
695
        final var pane = tab.getTabPane();
696
697
        if( pane != null ) {
698
          pane.requestFocus();
699
        }
700
      }
701
    } );
702
703
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
704
      if( nPane != null ) {
705
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
706
          if( n != null && n ) {
707
            final var selected = nPane.getSelectionModel().getSelectedItem();
708
            final var node = selected.getContent();
709
            node.requestFocus();
710
          }
711
        } );
712
      }
713
    } );
714
715
    return tab;
716
  }
717
718
  /**
719
   * Creates bins for the different {@link MediaType}s, which eventually are
720
   * added to the UI as separate tab panes. If ever a general-purpose scene
721
   * exporter is developed to serialize a scene to an FXML file, this could
722
   * be replaced by such a class.
723
   * <p>
724
   * When binning the files, this makes sure that at least one file exists
725
   * for every type. If the user has opted to close a particular type (such
726
   * as the definition pane), the view will suppressed elsewhere.
727
   * </p>
728
   * <p>
729
   * The order that the binned files are returned will be reflected in the
730
   * order that the corresponding panes are rendered in the UI.
731
   * </p>
732
   *
733
   * @param paths The file paths to bin according to their type.
734
   * @return An in-order list of files, first by structured definition files,
735
   * then by plain text documents.
736
   */
737
  private List<File> collect( final SetProperty<String> paths ) {
738
    // Treat all files destined for the text editor as plain text documents
739
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
740
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
741
    final Function<MediaType, MediaType> bin =
742
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
743
744
    // Create two groups: YAML files and plain text files. The order that
745
    // the elements are listed in the enumeration for media types determines
746
    // what files are loaded first. Variable definitions come before all other
747
    // plain text documents.
748
    final var bins = paths
749
      .stream()
750
      .collect(
751
        groupingBy(
752
          path -> bin.apply( MediaType.fromFilename( path ) ),
753
          () -> new TreeMap<>( Enum::compareTo ),
754
          Collectors.toList()
755
        )
756
      );
757
758
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
759
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
760
761
    final var result = new LinkedList<File>();
762
763
    // Ensure that the same types are listed together (keep insertion order).
764
    bins.forEach( ( mediaType, files ) -> result.addAll(
765
      files.stream().map( File::new ).toList() )
766
    );
767
768
    return result;
769
  }
770
771
  /**
772
   * Force the active editor to update, which will cause the processor
773
   * to re-evaluate the interpolated definition map thereby updating the
774
   * preview pane.
775
   *
776
   * @param editor Contains the source document to update in the preview pane.
777
   */
778
  private void process( final TextEditor editor ) {
779
    // Ensure processing does not run on the JavaFX thread, which frees the
780
    // text editor immediately for caret movement. The preview will have a
781
    // slight delay when catching up to the caret position.
782
    final var task = new Task<Void>() {
783
      @Override
784
      public Void call() {
785
        try {
786
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
787
          p.apply( editor == null ? "" : editor.getText() );
788
        } catch( final Exception ex ) {
789
          clue( ex );
790
        }
791
792
        return null;
793
      }
794
    };
795
796
    // TODO: Each time the editor successfully runs the processor the task is
797
    //   considered successful. Due to the rapid-fire nature of processing
798
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
799
    //   scroll each time.
800
    //   The algorithm:
801
    //   1. Peek at the oldest time.
802
    //   2. If the difference between the oldest time and current time exceeds
803
    //      250 milliseconds, then invoke the scrolling.
804
    //   3. Insert the current time into the circular queue.
805
    task.setOnSucceeded(
806
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
807
    );
808
809
    // Prevents multiple process requests from executing simultaneously (due
810
    // to having a restricted queue size).
811
    sExecutor.execute( task );
812
  }
813
814
  /**
815
   * Lazily creates a {@link TabPane} configured to listen for tab select
816
   * events. The tab pane is associated with a given media type so that
817
   * similar files can be grouped together.
818
   *
819
   * @param mediaType The media type to associate with the tab pane.
820
   * @return An instance of {@link TabPane} that will handle tab docking.
821
   */
822
  private TabPane obtainTabPane( final MediaType mediaType ) {
823
    for( final var pane : mTabPanes ) {
824
      for( final var tab : pane.getTabs() ) {
825
        final var node = tab.getContent();
826
827
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
828
          return pane;
829
        }
830
      }
831
    }
832
833
    final var pane = createTabPane();
834
    mTabPanes.add( pane );
835
    return pane;
836
  }
837
838
  /**
839
   * Creates an initialized {@link TabPane} instance.
840
   *
841
   * @return A new {@link TabPane} with all listeners configured.
842
   */
843
  private TabPane createTabPane() {
844
    final var tabPane = new DetachableTabPane();
845
846
    initStageOwnerFactory( tabPane );
847
    initTabListener( tabPane );
848
849
    return tabPane;
850
  }
851
852
  /**
853
   * When any {@link DetachableTabPane} is detached from the main window,
854
   * the stage owner factory must be given its parent window, which will
855
   * own the child window. The parent window is the {@link MainPane}'s
856
   * {@link Scene}'s {@link Window} instance.
857
   *
858
   * <p>
859
   * This will derives the new title from the main window title, incrementing
860
   * the window count to help uniquely identify the child windows.
861
   * </p>
862
   *
863
   * @param tabPane A new {@link DetachableTabPane} to configure.
864
   */
865
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
866
    tabPane.setStageOwnerFactory( ( stage ) -> {
867
      final var title = get(
868
        "Detach.tab.title",
869
        ((Stage) getWindow()).getTitle(), ++mWindowCount
870
      );
871
      stage.setTitle( title );
872
873
      return getScene().getWindow();
874
    } );
875
  }
876
877
  /**
878
   * Responsible for configuring the content of each {@link DetachableTab} when
879
   * it is added to the given {@link DetachableTabPane} instance.
880
   * <p>
881
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
882
   * is initialized to perform synchronized scrolling between the editor and
883
   * its preview window. Additionally, the last tab in the tab pane's list of
884
   * tabs is given focus.
885
   * </p>
886
   * <p>
887
   * Note that multiple tabs can be added simultaneously.
888
   * </p>
889
   *
890
   * @param tabPane A new {@link TabPane} to configure.
891
   */
892
  private void initTabListener( final TabPane tabPane ) {
893
    tabPane.getTabs().addListener(
894
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
895
        while( listener.next() ) {
896
          if( listener.wasAdded() ) {
897
            final var tabs = listener.getAddedSubList();
898
899
            tabs.forEach( tab -> {
900
              final var node = tab.getContent();
901
902
              if( node instanceof TextEditor ) {
903
                initScrollEventListener( tab );
904
              }
905
            } );
906
907
            // Select and give focus to the last tab opened.
908
            final var index = tabs.size() - 1;
909
            if( index >= 0 ) {
910
              final var tab = tabs.get( index );
911
              tabPane.getSelectionModel().select( tab );
912
              tab.getContent().requestFocus();
913
            }
914
          }
915
        }
916
      }
917
    );
918
  }
919
920
  /**
921
   * Synchronizes scrollbar positions between the given {@link Tab} that
922
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
923
   *
924
   * @param tab The container for an instance of {@link TextEditor}.
925
   */
926
  private void initScrollEventListener( final Tab tab ) {
927
    final var editor = (TextEditor) tab.getContent();
928
    final var scrollPane = editor.getScrollPane();
929
    final var scrollBar = mPreview.getVerticalScrollBar();
930
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
931
932
    handler.enabledProperty().bind( tab.selectedProperty() );
933
  }
934
935
  private void addTabPane( final int index, final TabPane tabPane ) {
936
    final var items = getItems();
937
938
    if( !items.contains( tabPane ) ) {
939
      items.add( index, tabPane );
940
    }
941
  }
942
943
  private void addTabPane( final TabPane tabPane ) {
944
    addTabPane( getItems().size(), tabPane );
945
  }
946
947
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
948
    final var w = getWorkspace();
949
950
    return builder()
951
      .with( Mutator::setDefinitions, this::getDefinitions )
952
      .with( Mutator::setLocale, w::getLocale )
953
      .with( Mutator::setMetadata, w::getMetadata )
954
      .with( Mutator::setThemePath, w::getThemePath )
955
      .with( Mutator::setCaret,
956
             () -> getTextEditor().getCaret() )
957
      .with( Mutator::setImageDir,
958
             () -> w.getFile( KEY_IMAGES_DIR ) )
959
      .with( Mutator::setImageOrder,
960
             () -> w.getString( KEY_IMAGES_ORDER ) )
961
      .with( Mutator::setImageServer,
962
             () -> w.getString( KEY_IMAGES_SERVER ) )
963
      .with( Mutator::setSigilBegan,
964
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
965
      .with( Mutator::setSigilEnded,
966
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
967
      .with( Mutator::setRScript,
968
             () -> w.getString( KEY_R_SCRIPT ) )
969
      .with( Mutator::setRWorkingDir,
970
             () -> w.getFile( KEY_R_DIR ).toPath() )
971
      .with( Mutator::setCurlQuotes,
972
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
973
      .with( Mutator::setAutoClean,
974
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
975
  }
976
977
  public ProcessorContext createProcessorContext() {
978
    return createProcessorContext( null, NONE );
979
  }
980
981
  /**
982
   * @param outputPath Used when exporting to a PDF file (binary).
983
   * @param format     Used when processors export to a new text format.
984
   * @return A new {@link ProcessorContext} to use when creating an instance of
985
   * {@link Processor}.
986
   */
987
  public ProcessorContext createProcessorContext(
988
    final Path outputPath, final ExportFormat format ) {
989
    final var textEditor = getTextEditor();
990
    final var inputPath = textEditor.getPath();
991
992
    return createProcessorContextBuilder()
993
      .with( Mutator::setInputPath, inputPath )
994
      .with( Mutator::setOutputPath, outputPath )
995
      .with( Mutator::setExportFormat, format )
996
      .build();
997
  }
998
999
  /**
1000
   * @param inputPath Used by {@link ProcessorFactory} to determine
1001
   *                  {@link Processor} type to create based on file type.
1002
   * @return A new {@link ProcessorContext} to use when creating an instance of
1003
   * {@link Processor}.
1004
   */
1005
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1006
    return createProcessorContextBuilder()
1007
      .with( Mutator::setInputPath, inputPath )
1008
      .with( Mutator::setExportFormat, NONE )
1009
      .build();
1010
  }
1011
1012
  private TextResource createTextResource( final File file ) {
1013
    // TODO: Create PlainTextEditor that's returned by default.
1014
    return MediaType.valueFrom( file ) == TEXT_YAML
1015
      ? createDefinitionEditor( file )
1016
      : createMarkdownEditor( file );
1017
  }
1018
1019
  /**
1020
   * Creates an instance of {@link MarkdownEditor} that listens for both
1021
   * caret change events and text change events. Text change events must
1022
   * take priority over caret change events because it's possible to change
1023
   * the text without moving the caret (e.g., delete selected text).
1024
   *
1025
   * @param inputFile The file containing contents for the text editor.
1026
   * @return A non-null text editor.
1027
   */
1028
  private TextResource createMarkdownEditor( final File inputFile ) {
1029
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1030
1031
    mProcessors.computeIfAbsent(
1032
      editor, p -> createProcessors(
1033
        createProcessorContext( inputFile.toPath() ),
1034
        createHtmlPreviewProcessor()
1035
      )
1036
    );
1037
1038
    // Listener for editor modifications or caret position changes.
1039
    editor.addDirtyListener( ( c, o, n ) -> {
1040
      if( n ) {
1041
        // Reset the status bar after changing the text.
1042
        clue();
1043
1044
        // Processing the text may update the status bar.
1045
        process( getTextEditor() );
1046
1047
        // Update the caret position in the status bar.
1048
        CaretMovedEvent.fire( editor.getCaret() );
10201049
      }
10211050
    } );
M src/main/java/com/keenwrite/MainScene.java
66
import com.keenwrite.preferences.Workspace;
77
import com.keenwrite.ui.actions.GuiCommands;
8
import com.keenwrite.ui.listeners.CaretListener;
8
import com.keenwrite.ui.listeners.CaretStatus;
99
import javafx.scene.Node;
1010
import javafx.scene.Parent;
...
2121
import static com.keenwrite.events.ScrollLockEvent.fireScrollLockEvent;
2222
import static com.keenwrite.events.StatusEvent.clue;
23
import static com.keenwrite.preferences.SkinProperty.toFilename;
2423
import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_CUSTOM;
2524
import static com.keenwrite.preferences.AppKeys.KEY_UI_SKIN_SELECTION;
25
import static com.keenwrite.preferences.SkinProperty.toFilename;
2626
import static com.keenwrite.ui.actions.ApplicationBars.*;
2727
import static javafx.application.Platform.runLater;
...
4545
    final var mainPane = createMainPane( workspace );
4646
    final var actions = createApplicationActions( mainPane );
47
    final var caretListener = createCaretListener( mainPane );
47
    final var caretStatus = createCaretStatus();
48
4849
    mMenuBar = setManagedLayout( createMenuBar( actions ) );
4950
    mToolBar = setManagedLayout( createToolBar() );
5051
    mStatusBar = setManagedLayout( createStatusBar() );
5152
52
    mStatusBar.getRightItems().add( caretListener );
53
    mStatusBar.getRightItems().add( caretStatus );
5354
5455
    final var appPane = new BorderPane();
...
9495
  }
9596
96
  public StatusBar getStatusBar() { return mStatusBar; }
97
  public StatusBar getStatusBar() {return mStatusBar;}
9798
9899
  private void initStylesheets( final Scene scene, final Workspace workspace ) {
...
176177
   * based on the active text editor.
177178
   *
178
   * @return The {@link CaretListener} responsible for updating the
179
   * @return The {@link CaretStatus} responsible for updating the
179180
   * {@link StatusBar} whenever the caret changes position.
180181
   */
181
  private CaretListener createCaretListener( final MainPane mainPane ) {
182
    return new CaretListener( mainPane.textEditorProperty() );
182
  private CaretStatus createCaretStatus() {
183
    return new CaretStatus();
183184
  }
184185
D src/main/java/com/keenwrite/PermissiveCertificate.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import javax.net.ssl.*;
5
import java.security.SecureRandom;
6
import java.security.cert.X509Certificate;
7
8
import static javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier;
9
import static javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory;
10
11
/**
12
 * Responsible for trusting all certificate chains. The purpose of this class
13
 * is to work-around certificate issues caused by software that blocks
14
 * HTTP requests. For example, zscaler may block HTTP requests to kroki.io
15
 * when generating diagrams.
16
 */
17
public final class PermissiveCertificate {
18
  /**
19
   * Create a trust manager that does not validate certificate chains.
20
   */
21
  private final static TrustManager[] TRUST_ALL_CERTS = new TrustManager[]{
22
    new X509TrustManager() {
23
      @Override
24
      public X509Certificate[] getAcceptedIssuers() {
25
        return new X509Certificate[ 0 ];
26
      }
27
28
      @Override
29
      public void checkClientTrusted(
30
        X509Certificate[] certs, String authType ) {
31
      }
32
33
      @Override
34
      public void checkServerTrusted(
35
        X509Certificate[] certs, String authType ) {
36
      }
37
    }
38
  };
39
40
  /**
41
   * Responsible for permitting all hostnames for making HTTP requests.
42
   */
43
  private static class PermissiveHostNameVerifier implements HostnameVerifier {
44
    @Override
45
    public boolean verify( final String hostname, final SSLSession session ) {
46
      return true;
47
    }
48
  }
49
50
  /**
51
   * Install the all-trusting trust manager. If this fails it means that in
52
   * certain situations the HTML preview may fail to render diagrams. A way
53
   * to work around the issue is to install a local server for generating
54
   * diagrams.
55
   */
56
  public static boolean installTrustManager() {
57
    try {
58
      final var context = SSLContext.getInstance( "SSL" );
59
      context.init( null, TRUST_ALL_CERTS, new SecureRandom() );
60
      setDefaultSSLSocketFactory( context.getSocketFactory() );
61
      setDefaultHostnameVerifier( new PermissiveHostNameVerifier() );
62
      return true;
63
    } catch( final Exception ex ) {
64
      return false;
65
    }
66
  }
67
68
  /**
69
   * Use {@link #installTrustManager()}.
70
   */
71
  private PermissiveCertificate() {
72
  }
73
}
741
D src/main/java/com/keenwrite/ScrollEventHandler.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.events.ScrollLockEvent;
5
import javafx.beans.property.BooleanProperty;
6
import javafx.beans.property.SimpleBooleanProperty;
7
import javafx.event.Event;
8
import javafx.event.EventHandler;
9
import javafx.scene.control.ScrollBar;
10
import javafx.scene.control.skin.ScrollBarSkin;
11
import javafx.scene.input.MouseEvent;
12
import javafx.scene.input.ScrollEvent;
13
import javafx.scene.layout.StackPane;
14
import org.fxmisc.flowless.VirtualizedScrollPane;
15
import org.fxmisc.richtext.StyleClassedTextArea;
16
import org.greenrobot.eventbus.Subscribe;
17
18
import javax.swing.*;
19
import java.util.function.Consumer;
20
21
import static com.keenwrite.events.Bus.register;
22
import static java.lang.Math.max;
23
import static java.lang.Math.min;
24
import static javafx.geometry.Orientation.VERTICAL;
25
import static javax.swing.SwingUtilities.invokeLater;
26
27
/**
28
 * Converts scroll events from {@link VirtualizedScrollPane} scroll bars to
29
 * an instance of {@link JScrollBar}.
30
 * <p>
31
 * Called to synchronize the scrolling areas for either scrolling with the
32
 * mouse or scrolling using the scrollbar's thumb. Both are required to avoid
33
 * scrolling on the estimatedScrollYProperty that occurs when text events
34
 * fire. Scrolling performed for text events are handled separately to ensure
35
 * the preview panel scrolls to the same position in the Markdown editor,
36
 * taking into account things like images, tables, and other potentially
37
 * long vertical presentation items.
38
 * </p>
39
 */
40
public final class ScrollEventHandler implements EventHandler<Event> {
41
42
  private final class MouseHandler implements EventHandler<MouseEvent> {
43
    private final EventHandler<? super MouseEvent> mOldHandler;
44
45
    /**
46
     * Constructs a new handler for mouse scrolling events.
47
     *
48
     * @param oldHandler Receives the event after scrolling takes place.
49
     */
50
    private MouseHandler( final EventHandler<? super MouseEvent> oldHandler ) {
51
      mOldHandler = oldHandler;
52
    }
53
54
    @Override
55
    public void handle( final MouseEvent event ) {
56
      ScrollEventHandler.this.handle( event );
57
      mOldHandler.handle( event );
58
    }
59
  }
60
61
  private final class ScrollHandler implements EventHandler<ScrollEvent> {
62
    @Override
63
    public void handle( final ScrollEvent event ) {
64
      ScrollEventHandler.this.handle( event );
65
    }
66
  }
67
68
  private final VirtualizedScrollPane<StyleClassedTextArea> mEditorScrollPane;
69
  private final JScrollBar mPreviewScrollBar;
70
  private final BooleanProperty mEnabled = new SimpleBooleanProperty();
71
72
  private boolean mLocked;
73
74
  /**
75
   * @param editorScrollPane Scroll event source (human movement).
76
   * @param previewScrollBar Scroll event destination (corresponding movement).
77
   */
78
  public ScrollEventHandler(
79
    final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane,
80
    final JScrollBar previewScrollBar ) {
81
    mEditorScrollPane = editorScrollPane;
82
    mPreviewScrollBar = previewScrollBar;
83
84
    mEditorScrollPane.addEventFilter( ScrollEvent.ANY, new ScrollHandler() );
85
86
    initVerticalScrollBarThumb(
87
      mEditorScrollPane,
88
      thumb -> {
89
        final var handler = new MouseHandler( thumb.getOnMouseDragged() );
90
        thumb.setOnMouseDragged( handler );
91
      }
92
    );
93
94
    register( this );
95
  }
96
97
  /**
98
   * Gets a property intended to be bound to selected property of the tab being
99
   * scrolled. This is required because there's only one preview pane but
100
   * multiple editor panes. Each editor pane maintains its own scroll position.
101
   *
102
   * @return A {@link BooleanProperty} representing whether the scroll
103
   * events for this tab are to be executed.
104
   */
105
  public BooleanProperty enabledProperty() {
106
    return mEnabled;
107
  }
108
109
  /**
110
   * Scrolls the preview scrollbar relative to the edit scrollbar. Algorithm
111
   * is based on Karl Tauber's ratio calculation.
112
   *
113
   * @param event Unused; either {@link MouseEvent} or {@link ScrollEvent}
114
   */
115
  @Override
116
  public void handle( final Event event ) {
117
    invokeLater( () -> {
118
      if( isEnabled() ) {
119
        // e is for editor pane
120
        final var eScrollPane = getEditorScrollPane();
121
        final var eScrollY =
122
          eScrollPane.estimatedScrollYProperty().getValue().intValue();
123
        final var eHeight = (int)
124
          (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
125
            - eScrollPane.getHeight());
126
        final var eRatio = eHeight > 0
127
          ? min( max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
128
129
        // p is for preview pane
130
        final var pScrollBar = getPreviewScrollBar();
131
        final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
132
        final var pScrollY = (int) (pHeight * eRatio);
133
134
        pScrollBar.setValue( pScrollY );
135
        pScrollBar.getParent().repaint();
136
      }
137
    } );
138
  }
139
140
  @Subscribe
141
  public void handle( final ScrollLockEvent event ) {
142
    mLocked = event.isLocked();
143
  }
144
145
  private void initVerticalScrollBarThumb(
146
    final VirtualizedScrollPane<StyleClassedTextArea> pane,
147
    final Consumer<StackPane> consumer ) {
148
    // When the skin property is set, the stack pane is available (not null).
149
    getVerticalScrollBar( pane ).skinProperty().addListener( ( c, o, n ) -> {
150
      for( final var node : ((ScrollBarSkin) n).getChildren() ) {
151
        // Brittle, but what can you do?
152
        if( node.getStyleClass().contains( "thumb" ) ) {
153
          consumer.accept( (StackPane) node );
154
        }
155
      }
156
    } );
157
  }
158
159
  /**
160
   * Returns the vertical {@link ScrollBar} instance associated with the
161
   * given scroll pane. This is {@code null}-safe because the scroll pane
162
   * initializes its vertical {@link ScrollBar} upon construction.
163
   *
164
   * @param pane The scroll pane that contains a vertical {@link ScrollBar}.
165
   * @return The vertical {@link ScrollBar} associated with the scroll pane.
166
   * @throws IllegalStateException Could not obtain the vertical scroll bar.
167
   */
168
  private ScrollBar getVerticalScrollBar(
169
    final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
170
171
    for( final var node : pane.getChildrenUnmodifiable() ) {
172
      if( node instanceof final ScrollBar scrollBar &&
173
        scrollBar.getOrientation() == VERTICAL ) {
174
        return scrollBar;
175
      }
176
    }
177
178
    throw new IllegalStateException( "No vertical scroll bar found." );
179
  }
180
181
  private boolean isEnabled() {
182
    // TODO: As a minor optimization, when this is set to false, it could remove
183
    // the MouseHandler and ScrollHandler so that events only dispatch to one
184
    // object (instead of one per editor tab).
185
    return mEnabled.get() && !mLocked;
186
  }
187
188
  private VirtualizedScrollPane<StyleClassedTextArea> getEditorScrollPane() {
189
    return mEditorScrollPane;
190
  }
191
192
  private JScrollBar getPreviewScrollBar() {
193
    return mPreviewScrollBar;
194
  }
195
}
1961
D src/main/java/com/keenwrite/VariableNameInjector.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import com.keenwrite.editors.TextDefinition;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.editors.definition.DefinitionTreeItem;
7
8
import java.util.function.UnaryOperator;
9
10
import static com.keenwrite.constants.Constants.*;
11
import static com.keenwrite.events.StatusEvent.clue;
12
13
/**
14
 * Provides the logic for injecting variable names within the editor.
15
 */
16
public final class VariableNameInjector {
17
18
  /**
19
   * Find a node that matches the current word and substitute the definition
20
   * reference.
21
   */
22
  public static void autoinsert(
23
    final TextEditor editor,
24
    final TextDefinition definitions,
25
    final UnaryOperator<String> operator ) {
26
    assert editor != null;
27
    assert definitions != null;
28
    assert operator != null;
29
30
    try {
31
      if( definitions.isEmpty() ) {
32
        clue( STATUS_DEFINITION_EMPTY );
33
      }
34
      else {
35
        final var indexes = editor.getCaretWord();
36
        final var word = editor.getText( indexes );
37
38
        if( word.isBlank() ) {
39
          clue( STATUS_DEFINITION_BLANK );
40
        }
41
        else {
42
          final var leaf = findLeaf( definitions, word );
43
44
          if( leaf == null ) {
45
            clue( STATUS_DEFINITION_MISSING, word );
46
          }
47
          else {
48
            editor.replaceText( indexes, operator.apply( leaf.toPath() ) );
49
            definitions.expand( leaf );
50
          }
51
        }
52
      }
53
    } catch( final Exception ex ) {
54
      clue( STATUS_DEFINITION_BLANK, ex );
55
    }
56
  }
57
58
  /**
59
   * Looks for the given word, matching first by exact, next by a starts-with
60
   * condition with diacritics replaced, then by containment.
61
   *
62
   * @param word Match the word by: exact, beginning, containment, or other.
63
   */
64
  @SuppressWarnings( "ConstantConditions" )
65
  private static DefinitionTreeItem<String> findLeaf(
66
    final TextDefinition definition, final String word ) {
67
    assert definition != null;
68
    assert word != null;
69
70
    DefinitionTreeItem<String> leaf = null;
71
72
    leaf = leaf == null ? definition.findLeafExact( word ) : leaf;
73
    leaf = leaf == null ? definition.findLeafStartsWith( word ) : leaf;
74
    leaf = leaf == null ? definition.findLeafContains( word ) : leaf;
75
    leaf = leaf == null ? definition.findLeafContainsNoCase( word ) : leaf;
76
77
    return leaf;
78
  }
79
80
  /**
81
   * Prevent instantiation.
82
   */
83
  private VariableNameInjector() {}
84
}
851
M src/main/java/com/keenwrite/cmdline/Arguments.java
11
package com.keenwrite.cmdline;
22
3
import com.fasterxml.jackson.databind.JsonNode;
4
import com.fasterxml.jackson.databind.ObjectMapper;
5
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
36
import com.keenwrite.ExportFormat;
4
import com.keenwrite.preferences.Key;
5
import com.keenwrite.preferences.KeyConfiguration;
67
import com.keenwrite.processors.ProcessorContext;
78
import com.keenwrite.processors.ProcessorContext.Mutator;
89
import picocli.CommandLine;
910
1011
import java.io.File;
12
import java.io.IOException;
13
import java.nio.file.Files;
1114
import java.nio.file.Path;
1215
import java.util.HashMap;
16
import java.util.Locale;
1317
import java.util.Map;
14
import java.util.Set;
18
import java.util.Map.Entry;
1519
import java.util.concurrent.Callable;
1620
import java.util.function.Consumer;
1721
18
import static com.keenwrite.preferences.AppKeys.*;
22
import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME;
1923
2024
/**
2125
 * Responsible for mapping command-line arguments to keys that are used by
22
 * the application. This class implements the {@link KeyConfiguration} as
23
 * an abstraction so that the CLI and GUI can reuse the same code, but without
24
 * the CLI needing to instantiate or initialize JavaFX.
26
 * the application.
2527
 */
2628
@CommandLine.Command(
2729
  name = "KeenWrite",
2830
  mixinStandardHelpOptions = true,
29
  description = "Plain text editor for editing with variables."
31
  description = "Plain text editor for editing with variables"
3032
)
3133
@SuppressWarnings( "unused" )
32
public final class Arguments implements Callable<Integer>, KeyConfiguration {
34
public final class Arguments implements Callable<Integer> {
3335
  @CommandLine.Option(
34
    names = {"-a", "--all"},
36
    names = {"--all"},
3537
    description =
36
      "Concatenate files in directory before processing (${DEFAULT-VALUE}).",
38
      "Concatenate files before processing (${DEFAULT-VALUE})",
3739
    defaultValue = "false"
3840
  )
39
  private boolean mAll;
41
  private boolean mConcatenate;
4042
4143
  @CommandLine.Option(
42
    names = {"-k", "--keep-files"},
44
    names = {"--keep-files"},
4345
    description =
44
      "Keep temporary build files (${DEFAULT-VALUE}).",
46
      "Retain temporary build files (${DEFAULT-VALUE})",
4547
    defaultValue = "false"
4648
  )
4749
  private boolean mKeepFiles;
50
51
  @CommandLine.Option(
52
    names = {"--curl-quotes"},
53
    description =
54
      "Replace straight quotes with curly quotes (${DEFAULT-VALUE})",
55
    defaultValue = "true"
56
  )
57
  private Boolean mCurlQuotes;
4858
4959
  @CommandLine.Option(
5060
    names = {"-d", "--debug"},
5161
    description =
52
      "Enable logging to the console (${DEFAULT-VALUE}).",
62
      "Enable logging to the console (${DEFAULT-VALUE})",
63
    paramLabel = "Boolean",
5364
    defaultValue = "false"
5465
  )
5566
  private boolean mDebug;
5667
5768
  @CommandLine.Option(
5869
    names = {"-i", "--input"},
5970
    description =
60
      "Set the file name to read.",
71
      "Source document file path",
6172
    paramLabel = "PATH",
6273
    defaultValue = "stdin",
6374
    required = true
6475
  )
6576
  private Path mPathInput;
6677
6778
  @CommandLine.Option(
68
    names = {"-f", "--format-type"},
79
    names = {"--format-type"},
6980
    description =
7081
      "Export type: html, md, pdf, xml (${DEFAULT-VALUE})",
7182
    paramLabel = "String",
7283
    defaultValue = "pdf",
7384
    required = true
7485
  )
7586
  private String mFormatType;
87
88
  @CommandLine.Option(
89
    names = {"--format-subtype-tex"},
90
    description =
91
      "Export subtype for HTML formats: svg, delimited",
92
    defaultValue = "",
93
    paramLabel = "String"
94
  )
95
  private String mFormatSubtype;
96
97
  @CommandLine.Option(
98
    names = {"--image-dir"},
99
    description =
100
      "Directory containing images",
101
    paramLabel = "DIR"
102
  )
103
  private File mImageDir;
104
105
  @CommandLine.Option(
106
    names = {"--image-order"},
107
    description =
108
      "Comma-separated image order (${DEFAULT-VALUE})",
109
    paramLabel = "String",
110
    defaultValue = "svg,pdf,png,jpg,tiff"
111
  )
112
  private String mImageOrder;
113
114
  @CommandLine.Option(
115
    names = {"--image-server"},
116
    description =
117
      "SVG diagram rendering service (${DEFAULT-VALUE})",
118
    paramLabel = "String",
119
    defaultValue = DIAGRAM_SERVER_NAME
120
  )
121
  private String mImageServer;
122
123
  @CommandLine.Option(
124
    names = {"--locale"},
125
    description =
126
      "Set localization (${DEFAULT-VALUE})",
127
    paramLabel = "String",
128
    defaultValue = "en"
129
  )
130
  private String mLocale;
76131
77132
  @CommandLine.Option(
78133
    names = {"-m", "--metadata"},
79134
    description =
80
      "Map metadata keys to values, variable names allowed.",
135
      "Map metadata keys to values, variable names allowed",
81136
    paramLabel = "key=value"
82137
  )
83138
  private Map<String, String> mMetadata;
84139
85140
  @CommandLine.Option(
86141
    names = {"-o", "--output"},
87142
    description =
88
      "Set the file name to write.",
143
      "Destination document file path",
89144
    paramLabel = "PATH",
90145
    defaultValue = "stdout",
91146
    required = true
92
  )
93
  private File mPathOutput;
94
95
  @CommandLine.Option(
96
    names = {"-p", "--images-path"},
97
    description =
98
      "Absolute path to images directory",
99
    paramLabel = "PATH"
100147
  )
101
  private Path mPathImages;
148
  private Path mPathOutput;
102149
103150
  @CommandLine.Option(
104151
    names = {"-q", "--quiet"},
105152
    description =
106
      "Suppress all status messages (${DEFAULT-VALUE}).",
153
      "Suppress all status messages (${DEFAULT-VALUE})",
107154
    defaultValue = "false"
108155
  )
109156
  private boolean mQuiet;
110157
111158
  @CommandLine.Option(
112
    names = {"-s", "--format-subtype-tex"},
159
    names = {"--r-dir"},
113160
    description =
114
      "Export subtype for HTML formats: svg, delimited",
115
    paramLabel = "String"
161
      "R working directory",
162
    paramLabel = "DIR"
116163
  )
117
  private String mFormatSubtype;
164
  private Path mRWorkingDir;
118165
119166
  @CommandLine.Option(
120
    names = {"-t", "--theme"},
167
    names = {"--r-script"},
121168
    description =
122
      "Full theme name file path to use when exporting as a PDF file.",
169
      "R bootstrap script file path",
123170
    paramLabel = "PATH"
124171
  )
125
  private Path mThemeName;
172
  private Path mRScriptPath;
126173
127174
  @CommandLine.Option(
128
    names = {"-x", "--image-extensions"},
175
    names = {"--sigil-opening"},
129176
    description =
130
      "Space-separated image file name extensions (${DEFAULT-VALUE}).",
177
      "Starting sigil for variable names (${DEFAULT-VALUE})",
131178
    paramLabel = "String",
132
    defaultValue = "svg pdf png jpg tiff"
179
    defaultValue = "{{"
133180
  )
134
  private Set<String> mImageExtensions;
181
  private String mSigilBegan;
182
183
  @CommandLine.Option(
184
    names = {"--sigil-closing"},
185
    description =
186
      "Ending sigil for variable names (${DEFAULT-VALUE})",
187
    paramLabel = "String",
188
    defaultValue = "}}"
189
  )
190
  private String mSigilEnded;
191
192
  @CommandLine.Option(
193
    names = {"--theme-dir"},
194
    description =
195
      "Theme directory",
196
    paramLabel = "DIR"
197
  )
198
  private Path mDirTheme;
135199
136200
  @CommandLine.Option(
137201
    names = {"-v", "--variables"},
138202
    description =
139
      "Set the file name containing variable definitions (${DEFAULT-VALUE}).",
140
    paramLabel = "FILE",
141
    defaultValue = "variables.yaml"
203
      "Variables file path",
204
    paramLabel = "PATH"
142205
  )
143206
  private Path mPathVariables;
144207
145208
  private final Consumer<Arguments> mLauncher;
146
147
  private final Map<Key, Object> mValues = new HashMap<>();
148209
149210
  public Arguments( final Consumer<Arguments> launcher ) {
150211
    mLauncher = launcher;
151212
  }
152
153
  public ProcessorContext createProcessorContext() {
154
    mValues.put( KEY_UI_RECENT_DOCUMENT, mPathInput );
155
    mValues.put( KEY_UI_RECENT_DEFINITION, mPathVariables );
156
    mValues.put( KEY_UI_RECENT_EXPORT, mPathOutput );
157
    mValues.put( KEY_IMAGES_DIR, mPathImages );
158
    mValues.put( KEY_TYPESET_CONTEXT_THEMES_PATH, mThemeName.getParent() );
159
    mValues.put( KEY_TYPESET_CONTEXT_THEME_SELECTION, mThemeName.getFileName() );
160
    mValues.put( KEY_TYPESET_CONTEXT_CLEAN, !mKeepFiles );
161213
214
  public ProcessorContext createProcessorContext()
215
    throws IOException {
216
    final var definitions = parse( mPathVariables );
162217
    final var format = ExportFormat.valueFrom( mFormatType, mFormatSubtype );
218
    final var locale = lookupLocale( mLocale );
219
    final var rScript = read( mRScriptPath );
163220
164221
    return ProcessorContext
165222
      .builder()
166223
      .with( Mutator::setInputPath, mPathInput )
167224
      .with( Mutator::setOutputPath, mPathOutput )
168225
      .with( Mutator::setExportFormat, format )
226
      .with( Mutator::setDefinitions, () -> definitions )
227
      .with( Mutator::setMetadata, () -> mMetadata )
228
      .with( Mutator::setLocale, () -> locale )
229
      .with( Mutator::setThemePath, () -> mDirTheme )
230
      .with( Mutator::setConcatenate, mConcatenate )
231
      .with( Mutator::setImageDir, () -> mImageDir )
232
      .with( Mutator::setImageServer, () -> mImageServer )
233
      .with( Mutator::setImageOrder, () -> mImageOrder )
234
      .with( Mutator::setSigilBegan, () -> mSigilBegan )
235
      .with( Mutator::setSigilEnded, () -> mSigilEnded )
236
      .with( Mutator::setRWorkingDir, () -> mRWorkingDir )
237
      .with( Mutator::setRScript, () -> rScript )
238
      .with( Mutator::setCurlQuotes, () -> mCurlQuotes )
239
      .with( Mutator::setAutoClean, () -> !mKeepFiles )
169240
      .build();
170241
  }
...
191262
  }
192263
193
  @Override
194
  public String getString( final Key key ) {
195
    return null;
264
  private static String read( final Path path ) throws IOException {
265
    return Files.readString( path );
196266
  }
197267
198
  @Override
199
  public boolean getBoolean( final Key key ) {
200
    return false;
268
  /**
269
   * Parses the given YAML document into a map of key-value pairs.
270
   *
271
   * @param vars Variable definition file to read, may be {@code null} if no
272
   *             variables are specified.
273
   * @return A non-interpolated variable map, or an empty map.
274
   * @throws IOException Could not read the variable definition file
275
   */
276
  private static Map<String, String> parse( final Path vars )
277
    throws IOException {
278
    final var map = new HashMap<String, String>();
279
280
    if( vars != null ) {
281
      final var yaml = read( vars );
282
      final var factory = new YAMLFactory();
283
      final var json = new ObjectMapper( factory ).readTree( yaml );
284
285
      parse( json, "", map );
286
    }
287
288
    return map;
201289
  }
202290
203
  @Override
204
  public int getInteger( final Key key ) {
205
    return 0;
291
  private static void parse(
292
    final JsonNode json, final String parent, final Map<String, String> map ) {
293
    assert json != null;
294
    assert parent != null;
295
    assert map != null;
296
297
    json.fields().forEachRemaining( node -> parse( node, parent, map ) );
206298
  }
207299
208
  @Override
209
  public double getDouble( final Key key ) {
210
    return 0;
300
  private static void parse(
301
    final Entry<String, JsonNode> node,
302
    final String parent,
303
    final Map<String, String> map ) {
304
    assert node != null;
305
    assert parent != null;
306
    assert map != null;
307
308
    final var jsonNode = node.getValue();
309
    final var keyName = parent + "." + node.getKey();
310
311
    if( jsonNode.isValueNode() ) {
312
      // Trim the leading period, which is always present.
313
      map.put( keyName.substring( 1 ), node.getValue().asText() );
314
    }
315
    else if( jsonNode.isObject() ) {
316
      parse( jsonNode, keyName, map );
317
    }
211318
  }
212319
213
  @Override
214
  public File getFile( final Key key ) {
215
    return null;
320
  private static Locale lookupLocale( final String locale ) {
321
    try {
322
      return Locale.forLanguageTag( locale );
323
    } catch( final Exception ex ) {
324
      return Locale.ENGLISH;
325
    }
216326
  }
217327
}
M src/main/java/com/keenwrite/collections/InterpolatingMap.java
5252
   * @return {@code this}
5353
   */
54
  public Map<String, String> interpolate() {
54
  public InterpolatingMap interpolate() {
5555
    for( final var k : keySet() ) {
5656
      replace( k, interpolate( get( k ) ) );
M src/main/java/com/keenwrite/editors/TextEditor.java
22
package com.keenwrite.editors;
33
4
import com.keenwrite.Caret;
4
import com.keenwrite.editors.common.Caret;
55
import javafx.scene.control.IndexRange;
66
import org.fxmisc.flowless.VirtualizedScrollPane;
A src/main/java/com/keenwrite/editors/common/Caret.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.editors.common;
3
4
import com.keenwrite.util.GenericBuilder;
5
6
import java.util.function.Supplier;
7
8
import static com.keenwrite.Messages.get;
9
import static com.keenwrite.constants.Constants.STATUS_BAR_LINE;
10
11
/**
12
 * Represents the absolute, relative, and maximum position of the caret. The
13
 * caret position is a character offset into the text.
14
 */
15
public class Caret {
16
17
  private final Mutator mMutator;
18
19
  public static GenericBuilder<Caret.Mutator, Caret> builder() {
20
    return GenericBuilder.of( Caret.Mutator::new, Caret::new );
21
  }
22
23
  /**
24
   * Configures a caret.
25
   */
26
  public static class Mutator {
27
    /**
28
     * Caret's current paragraph index (i.e., current caret line number).
29
     */
30
    private Supplier<Integer> mParagraph = () -> 0;
31
32
    /**
33
     * Used to count the number of lines in the text editor document.
34
     */
35
    private Supplier<Integer> mParagraphs = () -> 1;
36
37
    /**
38
     * Caret offset into the current paragraph, represented as a string index.
39
     */
40
    private Supplier<Integer> mParaOffset = () -> 0;
41
42
    /**
43
     * Caret offset into the full text, represented as a string index.
44
     */
45
    private Supplier<Integer> mTextOffset = () -> 0;
46
47
    /**
48
     * Total number of characters in the document.
49
     */
50
    private Supplier<Integer> mTextLength = () -> 0;
51
52
    /**
53
     * Sets the {@link Supplier} for the caret's current paragraph number.
54
     *
55
     * @param paragraph Returns the document caret paragraph index.
56
     */
57
    public void setParagraph( final Supplier<Integer> paragraph ) {
58
      assert paragraph != null;
59
      mParagraph = paragraph;
60
    }
61
62
    /**
63
     * Sets the {@link Supplier} for the total number of document paragraphs.
64
     *
65
     * @param paragraphs Returns the document paragraph count.
66
     */
67
    public void setParagraphs( final Supplier<Integer> paragraphs ) {
68
      assert paragraphs != null;
69
      mParagraphs = paragraphs;
70
    }
71
72
    /**
73
     * Sets the {@link Supplier} for the caret's current character offset
74
     * into the current paragraph.
75
     *
76
     * @param paraOffset Returns the caret's paragraph character index.
77
     */
78
    public void setParaOffset( final Supplier<Integer> paraOffset ) {
79
      assert paraOffset != null;
80
      mParaOffset = paraOffset;
81
    }
82
83
    /**
84
     * Sets the {@link Supplier} for the caret's current document position.
85
     * A value of 0 represents the start of the document.
86
     *
87
     * @param textOffset Returns the text offset into the current document.
88
     */
89
    public void setTextOffset( final Supplier<Integer> textOffset ) {
90
      assert textOffset != null;
91
      mTextOffset = textOffset;
92
    }
93
94
    /**
95
     * Sets the {@link Supplier} for the document's total character count.
96
     *
97
     * @param textLength Returns the total character count in the document.
98
     */
99
    public void setTextLength( final Supplier<Integer> textLength ) {
100
      assert textLength != null;
101
      mTextLength = textLength;
102
    }
103
  }
104
105
  /**
106
   * Force using the builder pattern.
107
   */
108
  private Caret( final Mutator mutator ) {
109
    assert mutator != null;
110
111
    mMutator = mutator;
112
  }
113
114
  /**
115
   * Answers whether the caret's offset into the text is between the given
116
   * offsets.
117
   *
118
   * @param began Starting value compared against the caret's text offset.
119
   * @param ended Ending value compared against the caret's text offset.
120
   * @return {@code true} when the caret's text offset is between the given
121
   * values, inclusively (for either value).
122
   */
123
  public boolean isBetweenText( final int began, final int ended ) {
124
    final var offset = getTextOffset();
125
    return began <= offset && offset <= ended;
126
  }
127
128
  /**
129
   * Answers whether the caret's offset into the paragraph is before the given
130
   * offset.
131
   *
132
   * @param offset Compared against the caret's paragraph offset.
133
   * @return {@code true} the caret's offset is before the given offset.
134
   */
135
  public boolean isBeforeColumn( final int offset ) {
136
    return getParaOffset() < offset;
137
  }
138
139
  /**
140
   * Answers whether the caret's offset into the text is before the given
141
   * text offset.
142
   *
143
   * @param offset Compared against the caret's text offset.
144
   * @return {@code true} the caret's offset is after the given offset.
145
   */
146
  public boolean isAfterColumn( final int offset ) {
147
    return getParaOffset() > offset;
148
  }
149
150
  /**
151
   * Answers whether the caret's offset into the text exceeds the length of
152
   * the text.
153
   *
154
   * @return {@code true} when the caret is at the end of the text boundary.
155
   */
156
  public boolean isAfterText() {
157
    return getTextOffset() >= getTextLength();
158
  }
159
160
  public boolean isAfter( final int offset ) {
161
    return offset >= getTextOffset();
162
  }
163
164
  private int getParagraph() {
165
    return mMutator.mParagraph.get();
166
  }
167
168
  /**
169
   * Returns the number of lines in the text editor.
170
   *
171
   * @return The size of the text editor's paragraph list plus one.
172
   */
173
  private int getParagraphCount() {
174
    return mMutator.mParagraphs.get();
175
  }
176
177
  /**
178
   * Returns the absolute position of the caret within the entire document.
179
   *
180
   * @return A zero-based index of the caret position.
181
   */
182
  private int getTextOffset() {
183
    return mMutator.mTextOffset.get();
184
  }
185
186
  /**
187
   * Returns the position of the caret within the current paragraph being
188
   * edited.
189
   *
190
   * @return A zero-based index of the caret position relative to the
191
   * current paragraph.
192
   */
193
  private int getParaOffset() {
194
    return mMutator.mParaOffset.get();
195
  }
196
197
  /**
198
   * Returns the total number of characters in the document being edited.
199
   *
200
   * @return A zero-based count of the total characters in the document.
201
   */
202
  private int getTextLength() {
203
    return mMutator.mTextLength.get();
204
  }
205
206
  /**
207
   * Returns a human-readable string that shows the current caret position
208
   * within the text. Typically, this will include the current line number,
209
   * the number of lines, and the character offset into the text.
210
   * <p>
211
   * If the {@link Caret} has not been properly built, this will return a
212
   * string for the status bar having all values set to zero. This can happen
213
   * during unit testing.
214
   *
215
   * @return A string to present to an end user.
216
   */
217
  @Override
218
  public String toString() {
219
    try {
220
      return get( STATUS_BAR_LINE,
221
                  getParagraph() + 1,
222
                  getParagraphCount(),
223
                  getTextOffset() + 1 );
224
    } catch( final Exception ex ) {
225
      return get( STATUS_BAR_LINE, 0, 0, 0 );
226
    }
227
  }
228
}
1229
A src/main/java/com/keenwrite/editors/common/ScrollEventHandler.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.editors.common;
3
4
import com.keenwrite.events.ScrollLockEvent;
5
import javafx.beans.property.BooleanProperty;
6
import javafx.beans.property.SimpleBooleanProperty;
7
import javafx.event.Event;
8
import javafx.event.EventHandler;
9
import javafx.scene.control.ScrollBar;
10
import javafx.scene.control.skin.ScrollBarSkin;
11
import javafx.scene.input.MouseEvent;
12
import javafx.scene.input.ScrollEvent;
13
import javafx.scene.layout.StackPane;
14
import org.fxmisc.flowless.VirtualizedScrollPane;
15
import org.fxmisc.richtext.StyleClassedTextArea;
16
import org.greenrobot.eventbus.Subscribe;
17
18
import javax.swing.*;
19
import java.util.function.Consumer;
20
21
import static com.keenwrite.events.Bus.register;
22
import static java.lang.Math.max;
23
import static java.lang.Math.min;
24
import static javafx.geometry.Orientation.VERTICAL;
25
import static javax.swing.SwingUtilities.invokeLater;
26
27
/**
28
 * Converts scroll events from {@link VirtualizedScrollPane} scroll bars to
29
 * an instance of {@link JScrollBar}.
30
 * <p>
31
 * Called to synchronize the scrolling areas for either scrolling with the
32
 * mouse or scrolling using the scrollbar's thumb. Both are required to avoid
33
 * scrolling on the estimatedScrollYProperty that occurs when text events
34
 * fire. Scrolling performed for text events are handled separately to ensure
35
 * the preview panel scrolls to the same position in the Markdown editor,
36
 * taking into account things like images, tables, and other potentially
37
 * long vertical presentation items.
38
 * </p>
39
 */
40
public final class ScrollEventHandler implements EventHandler<Event> {
41
42
  private final class MouseHandler implements EventHandler<MouseEvent> {
43
    private final EventHandler<? super MouseEvent> mOldHandler;
44
45
    /**
46
     * Constructs a new handler for mouse scrolling events.
47
     *
48
     * @param oldHandler Receives the event after scrolling takes place.
49
     */
50
    private MouseHandler( final EventHandler<? super MouseEvent> oldHandler ) {
51
      mOldHandler = oldHandler;
52
    }
53
54
    @Override
55
    public void handle( final MouseEvent event ) {
56
      ScrollEventHandler.this.handle( event );
57
      mOldHandler.handle( event );
58
    }
59
  }
60
61
  private final class ScrollHandler implements EventHandler<ScrollEvent> {
62
    @Override
63
    public void handle( final ScrollEvent event ) {
64
      ScrollEventHandler.this.handle( event );
65
    }
66
  }
67
68
  private final VirtualizedScrollPane<StyleClassedTextArea> mEditorScrollPane;
69
  private final JScrollBar mPreviewScrollBar;
70
  private final BooleanProperty mEnabled = new SimpleBooleanProperty();
71
72
  private boolean mLocked;
73
74
  /**
75
   * @param editorScrollPane Scroll event source (human movement).
76
   * @param previewScrollBar Scroll event destination (corresponding movement).
77
   */
78
  public ScrollEventHandler(
79
    final VirtualizedScrollPane<StyleClassedTextArea> editorScrollPane,
80
    final JScrollBar previewScrollBar ) {
81
    mEditorScrollPane = editorScrollPane;
82
    mPreviewScrollBar = previewScrollBar;
83
84
    mEditorScrollPane.addEventFilter( ScrollEvent.ANY, new ScrollHandler() );
85
86
    initVerticalScrollBarThumb(
87
      mEditorScrollPane,
88
      thumb -> {
89
        final var handler = new MouseHandler( thumb.getOnMouseDragged() );
90
        thumb.setOnMouseDragged( handler );
91
      }
92
    );
93
94
    register( this );
95
  }
96
97
  /**
98
   * Gets a property intended to be bound to selected property of the tab being
99
   * scrolled. This is required because there's only one preview pane but
100
   * multiple editor panes. Each editor pane maintains its own scroll position.
101
   *
102
   * @return A {@link BooleanProperty} representing whether the scroll
103
   * events for this tab are to be executed.
104
   */
105
  public BooleanProperty enabledProperty() {
106
    return mEnabled;
107
  }
108
109
  /**
110
   * Scrolls the preview scrollbar relative to the edit scrollbar. Algorithm
111
   * is based on Karl Tauber's ratio calculation.
112
   *
113
   * @param event Unused; either {@link MouseEvent} or {@link ScrollEvent}
114
   */
115
  @Override
116
  public void handle( final Event event ) {
117
    invokeLater( () -> {
118
      if( isEnabled() ) {
119
        // e is for editor pane
120
        final var eScrollPane = getEditorScrollPane();
121
        final var eScrollY =
122
          eScrollPane.estimatedScrollYProperty().getValue().intValue();
123
        final var eHeight = (int)
124
          (eScrollPane.totalHeightEstimateProperty().getValue().intValue()
125
            - eScrollPane.getHeight());
126
        final var eRatio = eHeight > 0
127
          ? min( max( eScrollY / (float) eHeight, 0 ), 1 ) : 0;
128
129
        // p is for preview pane
130
        final var pScrollBar = getPreviewScrollBar();
131
        final var pHeight = pScrollBar.getMaximum() - pScrollBar.getHeight();
132
        final var pScrollY = (int) (pHeight * eRatio);
133
134
        pScrollBar.setValue( pScrollY );
135
        pScrollBar.getParent().repaint();
136
      }
137
    } );
138
  }
139
140
  @Subscribe
141
  public void handle( final ScrollLockEvent event ) {
142
    mLocked = event.isLocked();
143
  }
144
145
  private void initVerticalScrollBarThumb(
146
    final VirtualizedScrollPane<StyleClassedTextArea> pane,
147
    final Consumer<StackPane> consumer ) {
148
    // When the skin property is set, the stack pane is available (not null).
149
    getVerticalScrollBar( pane ).skinProperty().addListener( ( c, o, n ) -> {
150
      for( final var node : ((ScrollBarSkin) n).getChildren() ) {
151
        // Brittle, but what can you do?
152
        if( node.getStyleClass().contains( "thumb" ) ) {
153
          consumer.accept( (StackPane) node );
154
        }
155
      }
156
    } );
157
  }
158
159
  /**
160
   * Returns the vertical {@link ScrollBar} instance associated with the
161
   * given scroll pane. This is {@code null}-safe because the scroll pane
162
   * initializes its vertical {@link ScrollBar} upon construction.
163
   *
164
   * @param pane The scroll pane that contains a vertical {@link ScrollBar}.
165
   * @return The vertical {@link ScrollBar} associated with the scroll pane.
166
   * @throws IllegalStateException Could not obtain the vertical scroll bar.
167
   */
168
  private ScrollBar getVerticalScrollBar(
169
    final VirtualizedScrollPane<StyleClassedTextArea> pane ) {
170
171
    for( final var node : pane.getChildrenUnmodifiable() ) {
172
      if( node instanceof final ScrollBar scrollBar &&
173
        scrollBar.getOrientation() == VERTICAL ) {
174
        return scrollBar;
175
      }
176
    }
177
178
    throw new IllegalStateException( "No vertical scroll bar found." );
179
  }
180
181
  private boolean isEnabled() {
182
    // TODO: As a minor optimization, when this is set to false, it could remove
183
    // the MouseHandler and ScrollHandler so that events only dispatch to one
184
    // object (instead of one per editor tab).
185
    return mEnabled.get() && !mLocked;
186
  }
187
188
  private VirtualizedScrollPane<StyleClassedTextArea> getEditorScrollPane() {
189
    return mEditorScrollPane;
190
  }
191
192
  private JScrollBar getPreviewScrollBar() {
193
    return mPreviewScrollBar;
194
  }
195
}
1196
A src/main/java/com/keenwrite/editors/common/VariableNameInjector.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.editors.common;
3
4
import com.keenwrite.editors.TextDefinition;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.editors.definition.DefinitionTreeItem;
7
8
import java.util.function.UnaryOperator;
9
10
import static com.keenwrite.constants.Constants.*;
11
import static com.keenwrite.events.StatusEvent.clue;
12
13
/**
14
 * Provides the logic for injecting variable names within the editor.
15
 */
16
public final class VariableNameInjector {
17
18
  /**
19
   * Find a node that matches the current word and substitute the definition
20
   * reference.
21
   */
22
  public static void autoinsert(
23
    final TextEditor editor,
24
    final TextDefinition definitions,
25
    final UnaryOperator<String> operator ) {
26
    assert editor != null;
27
    assert definitions != null;
28
    assert operator != null;
29
30
    try {
31
      if( definitions.isEmpty() ) {
32
        clue( STATUS_DEFINITION_EMPTY );
33
      }
34
      else {
35
        final var indexes = editor.getCaretWord();
36
        final var word = editor.getText( indexes );
37
38
        if( word.isBlank() ) {
39
          clue( STATUS_DEFINITION_BLANK );
40
        }
41
        else {
42
          final var leaf = findLeaf( definitions, word );
43
44
          if( leaf == null ) {
45
            clue( STATUS_DEFINITION_MISSING, word );
46
          }
47
          else {
48
            editor.replaceText( indexes, operator.apply( leaf.toPath() ) );
49
            definitions.expand( leaf );
50
          }
51
        }
52
      }
53
    } catch( final Exception ex ) {
54
      clue( STATUS_DEFINITION_BLANK, ex );
55
    }
56
  }
57
58
  /**
59
   * Looks for the given word, matching first by exact, next by a starts-with
60
   * condition with diacritics replaced, then by containment.
61
   *
62
   * @param word Match the word by: exact, beginning, containment, or other.
63
   */
64
  @SuppressWarnings( "ConstantConditions" )
65
  private static DefinitionTreeItem<String> findLeaf(
66
    final TextDefinition definition, final String word ) {
67
    assert definition != null;
68
    assert word != null;
69
70
    DefinitionTreeItem<String> leaf = null;
71
72
    leaf = leaf == null ? definition.findLeafExact( word ) : leaf;
73
    leaf = leaf == null ? definition.findLeafStartsWith( word ) : leaf;
74
    leaf = leaf == null ? definition.findLeafContains( word ) : leaf;
75
    leaf = leaf == null ? definition.findLeafContainsNoCase( word ) : leaf;
76
77
    return leaf;
78
  }
79
80
  /**
81
   * Prevent instantiation.
82
   */
83
  private VariableNameInjector() {}
84
}
185
M src/main/java/com/keenwrite/editors/definition/DefinitionTreeItem.java
7979
   * null} if there was no match found.
8080
   */
81
  @SuppressWarnings( "AssignmentUsedAsCondition" )
8182
  public DefinitionTreeItem<T> findLeaf(
8283
    final String text,
...
168169
   */
169170
  public String toPath() {
170
    return new TreeItemMapper().toPath( getParent() );
171
    return TreeItemMapper.toPath( getParent() );
171172
  }
172173
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
22
package com.keenwrite.editors.markdown;
33
4
import com.keenwrite.Caret;
5
import com.keenwrite.constants.Constants;
6
import com.keenwrite.editors.TextEditor;
7
import com.keenwrite.events.TextEditorFocusEvent;
8
import com.keenwrite.io.MediaType;
9
import com.keenwrite.preferences.LocaleProperty;
10
import com.keenwrite.preferences.Workspace;
11
import com.keenwrite.spelling.impl.TextEditorSpeller;
12
import javafx.beans.binding.Bindings;
13
import javafx.beans.property.*;
14
import javafx.beans.value.ChangeListener;
15
import javafx.event.Event;
16
import javafx.scene.Node;
17
import javafx.scene.control.ContextMenu;
18
import javafx.scene.control.IndexRange;
19
import javafx.scene.control.MenuItem;
20
import javafx.scene.input.KeyEvent;
21
import javafx.scene.layout.BorderPane;
22
import org.fxmisc.flowless.VirtualizedScrollPane;
23
import org.fxmisc.richtext.StyleClassedTextArea;
24
import org.fxmisc.richtext.model.StyleSpans;
25
import org.fxmisc.undo.UndoManager;
26
import org.fxmisc.wellbehaved.event.EventPattern;
27
import org.fxmisc.wellbehaved.event.Nodes;
28
29
import java.io.File;
30
import java.nio.charset.Charset;
31
import java.text.BreakIterator;
32
import java.text.MessageFormat;
33
import java.util.*;
34
import java.util.function.Consumer;
35
import java.util.function.Supplier;
36
import java.util.regex.Pattern;
37
38
import static com.keenwrite.MainApp.keyDown;
39
import static com.keenwrite.constants.Constants.*;
40
import static com.keenwrite.events.StatusEvent.clue;
41
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
42
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
43
import static com.keenwrite.preferences.AppKeys.*;
44
import static java.lang.Character.isWhitespace;
45
import static java.lang.String.format;
46
import static java.util.Collections.singletonList;
47
import static javafx.application.Platform.runLater;
48
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
49
import static javafx.scene.input.KeyCode.*;
50
import static javafx.scene.input.KeyCombination.*;
51
import static org.apache.commons.lang3.StringUtils.stripEnd;
52
import static org.apache.commons.lang3.StringUtils.stripStart;
53
import static org.fxmisc.richtext.model.StyleSpans.singleton;
54
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
55
import static org.fxmisc.wellbehaved.event.InputMap.consume;
56
57
/**
58
 * Responsible for editing Markdown documents.
59
 */
60
public final class MarkdownEditor extends BorderPane implements TextEditor {
61
  /**
62
   * Regular expression that matches the type of markup block. This is used
63
   * when Enter is pressed to continue the block environment.
64
   */
65
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
66
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
67
68
  private final Workspace mWorkspace;
69
70
  /**
71
   * The text editor.
72
   */
73
  private final StyleClassedTextArea mTextArea =
74
    new StyleClassedTextArea( false );
75
76
  /**
77
   * Wraps the text editor in scrollbars.
78
   */
79
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
80
    new VirtualizedScrollPane<>( mTextArea );
81
82
  /**
83
   * Tracks where the caret is located in this document. This offers observable
84
   * properties for caret position changes.
85
   */
86
  private final Caret mCaret = createCaret( mTextArea );
87
88
  /**
89
   * For spell checking the document upon load and whenever it changes.
90
   */
91
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
92
93
  /**
94
   * File being edited by this editor instance.
95
   */
96
  private File mFile;
97
98
  /**
99
   * Set to {@code true} upon text or caret position changes. Value is {@code
100
   * false} by default.
101
   */
102
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
103
104
  /**
105
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
106
   * either no encoding could be determined or this is a new (empty) file.
107
   */
108
  private final Charset mEncoding;
109
110
  /**
111
   * Tracks whether the in-memory definitions have changed with respect to the
112
   * persisted definitions.
113
   */
114
  private final BooleanProperty mModified = new SimpleBooleanProperty();
115
116
  public MarkdownEditor( final Workspace workspace ) {
117
    this( DOCUMENT_DEFAULT, workspace );
118
  }
119
120
  public MarkdownEditor( final File file, final Workspace workspace ) {
121
    mEncoding = open( mFile = file );
122
    mWorkspace = workspace;
123
124
    initTextArea( mTextArea );
125
    initStyle( mTextArea );
126
    initScrollPane( mScrollPane );
127
    initSpellchecker( mTextArea );
128
    initHotKeys();
129
    initUndoManager();
130
  }
131
132
  private void initTextArea( final StyleClassedTextArea textArea ) {
133
    textArea.setWrapText( true );
134
    textArea.requestFollowCaret();
135
    textArea.moveTo( 0 );
136
137
    textArea.textProperty().addListener( ( c, o, n ) -> {
138
      // Fire, regardless of whether the caret position has changed.
139
      mDirty.set( false );
140
141
      // Prevent the subsequent caret position change from raising dirty bits.
142
      mDirty.set( true );
143
    } );
144
145
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
146
      // Fire when the caret position has changed and the text has not.
147
      mDirty.set( true );
148
      mDirty.set( false );
149
    } );
150
151
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
152
      if( n != null && n ) {
153
        TextEditorFocusEvent.fire( this );
154
      }
155
    } );
156
  }
157
158
  private void initStyle( final StyleClassedTextArea textArea ) {
159
    textArea.getStyleClass().add( "markdown" );
160
161
    final var stylesheets = textArea.getStylesheets();
162
    stylesheets.add( getStylesheetPath( getLocale() ) );
163
164
    localeProperty().addListener( ( c, o, n ) -> {
165
      if( n != null ) {
166
        stylesheets.clear();
167
        stylesheets.add( getStylesheetPath( getLocale() ) );
168
      }
169
    } );
170
171
    fontNameProperty().addListener(
172
      ( c, o, n ) ->
173
        setFont( mTextArea, getFontName(), getFontSize() )
174
    );
175
176
    fontSizeProperty().addListener(
177
      ( c, o, n ) ->
178
        setFont( mTextArea, getFontName(), getFontSize() )
179
    );
180
181
    setFont( mTextArea, getFontName(), getFontSize() );
182
  }
183
184
  private void initScrollPane(
185
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
186
    scrollpane.setVbarPolicy( ALWAYS );
187
    setCenter( scrollpane );
188
  }
189
190
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
191
    mSpeller.checkDocument( textarea );
192
    mSpeller.checkParagraphs( textarea );
193
  }
194
195
  private void initHotKeys() {
196
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
197
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
198
    addEventListener( keyPressed( TAB ), this::tab );
199
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
200
    addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
201
  }
202
203
  private void initUndoManager() {
204
    final var undoManager = getUndoManager();
205
    final var markedPosition = undoManager.atMarkedPositionProperty();
206
207
    undoManager.forgetHistory();
208
    undoManager.mark();
209
    mModified.bind( Bindings.not( markedPosition ) );
210
  }
211
212
  @Override
213
  public void moveTo( final int offset ) {
214
    assert 0 <= offset && offset <= mTextArea.getLength();
215
216
    mTextArea.moveTo( offset );
217
    mTextArea.requestFollowCaret();
218
  }
219
220
  /**
221
   * Delegate the focus request to the text area itself.
222
   */
223
  @Override
224
  public void requestFocus() {
225
    mTextArea.requestFocus();
226
  }
227
228
  @Override
229
  public void setText( final String text ) {
230
    mTextArea.clear();
231
    mTextArea.appendText( text );
232
    mTextArea.getUndoManager().mark();
233
  }
234
235
  @Override
236
  public String getText() {
237
    return mTextArea.getText();
238
  }
239
240
  @Override
241
  public Charset getEncoding() {
242
    return mEncoding;
243
  }
244
245
  @Override
246
  public File getFile() {
247
    return mFile;
248
  }
249
250
  @Override
251
  public void rename( final File file ) {
252
    mFile = file;
253
  }
254
255
  @Override
256
  public void undo() {
257
    final var manager = getUndoManager();
258
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
259
  }
260
261
  @Override
262
  public void redo() {
263
    final var manager = getUndoManager();
264
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
265
  }
266
267
  /**
268
   * Performs an undo or redo action, if possible, otherwise displays an error
269
   * message to the user.
270
   *
271
   * @param ready  Answers whether the action can be executed.
272
   * @param action The action to execute.
273
   * @param key    The informational message key having a value to display if
274
   *               the {@link Supplier} is not ready.
275
   */
276
  private void xxdo(
277
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
278
    if( ready.get() ) {
279
      action.run();
280
    }
281
    else {
282
      clue( key );
283
    }
284
  }
285
286
  @Override
287
  public void cut() {
288
    final var selected = mTextArea.getSelectedText();
289
290
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
291
    if( selected == null || selected.isEmpty() ) {
292
      // Note: mTextArea.selectLine() does not select empty lines.
293
      mTextArea.fireEvent( keyDown( HOME, false ) );
294
      mTextArea.fireEvent( keyDown( DOWN, true ) );
295
    }
296
297
    mTextArea.cut();
298
  }
299
300
  @Override
301
  public void copy() {
302
    mTextArea.copy();
303
  }
304
305
  @Override
306
  public void paste() {
307
    mTextArea.paste();
308
  }
309
310
  @Override
311
  public void selectAll() {
312
    mTextArea.selectAll();
313
  }
314
315
  @Override
316
  public void bold() {
317
    enwrap( "**" );
318
  }
319
320
  @Override
321
  public void italic() {
322
    enwrap( "*" );
323
  }
324
325
  @Override
326
  public void monospace() {
327
    enwrap( "`" );
328
  }
329
330
  @Override
331
  public void superscript() {
332
    enwrap( "^" );
333
  }
334
335
  @Override
336
  public void subscript() {
337
    enwrap( "~" );
338
  }
339
340
  @Override
341
  public void strikethrough() {
342
    enwrap( "~~" );
343
  }
344
345
  @Override
346
  public void blockquote() {
347
    block( "> " );
348
  }
349
350
  @Override
351
  public void code() {
352
    enwrap( "`" );
353
  }
354
355
  @Override
356
  public void fencedCodeBlock() {
357
    enwrap( "\n\n```\n", "\n```\n\n" );
358
  }
359
360
  @Override
361
  public void heading( final int level ) {
362
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
363
    block( format( "%s ", hashes ) );
364
  }
365
366
  @Override
367
  public void unorderedList() {
368
    block( "* " );
369
  }
370
371
  @Override
372
  public void orderedList() {
373
    block( "1. " );
374
  }
375
376
  @Override
377
  public void horizontalRule() {
378
    block( format( "---%n%n" ) );
379
  }
380
381
  @Override
382
  public Node getNode() {
383
    return this;
384
  }
385
386
  @Override
387
  public ReadOnlyBooleanProperty modifiedProperty() {
388
    return mModified;
389
  }
390
391
  @Override
392
  public void clearModifiedProperty() {
393
    getUndoManager().mark();
394
  }
395
396
  @Override
397
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
398
    return mScrollPane;
399
  }
400
401
  @Override
402
  public StyleClassedTextArea getTextArea() {
403
    return mTextArea;
404
  }
405
406
  private final Map<String, IndexRange> mStyles = new HashMap<>();
407
408
  @Override
409
  public void stylize( final IndexRange range, final String style ) {
410
    final var began = range.getStart();
411
    final var ended = range.getEnd() + 1;
412
413
    assert 0 <= began && began <= ended;
414
    assert style != null;
415
416
    // TODO: Ensure spell check and find highlights can coexist.
417
//    final var spans = mTextArea.getStyleSpans( range );
418
//    System.out.println( "SPANS: " + spans );
419
420
//    final var spans = mTextArea.getStyleSpans( range );
421
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
422
//    ) );
423
424
//    final var builder = new StyleSpansBuilder<Collection<String>>();
425
//    builder.add( singleton( style ), range.getLength() + 1 );
426
//    mTextArea.setStyleSpans( began, builder.create() );
427
428
//    final var s = mTextArea.getStyleSpans( began, ended );
429
//    System.out.println( "STYLES: " +s );
430
431
    mStyles.put( style, range );
432
    mTextArea.setStyleClass( began, ended, style );
433
434
    // Ensure that whenever the user interacts with the text that the found
435
    // word will have its highlighting removed. The handler removes itself.
436
    // This won't remove the highlighting if the caret position moves by mouse.
437
    final var handler = mTextArea.getOnKeyPressed();
438
    mTextArea.setOnKeyPressed( ( event ) -> {
439
      mTextArea.setOnKeyPressed( handler );
440
      unstylize( style );
441
    } );
442
443
    //mTextArea.setStyleSpans(began, ended, s);
444
  }
445
446
  private static StyleSpans<Collection<String>> merge(
447
    StyleSpans<Collection<String>> spans, int len, String style ) {
448
    spans = spans.overlay(
449
      singleton( singletonList( style ), len ),
450
      ( bottomSpan, list ) -> {
451
        final List<String> l =
452
          new ArrayList<>( bottomSpan.size() + list.size() );
453
        l.addAll( bottomSpan );
454
        l.addAll( list );
455
        return l;
456
      } );
457
458
    return spans;
459
  }
460
461
  @Override
462
  public void unstylize( final String style ) {
463
    final var indexes = mStyles.remove( style );
464
    if( indexes != null ) {
465
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
466
    }
467
  }
468
469
  @Override
470
  public Caret getCaret() {
471
    return mCaret;
472
  }
473
474
  private Caret createCaret( final StyleClassedTextArea editor ) {
475
    return Caret
476
      .builder()
477
      .with( Caret.Mutator::setEditor, editor )
4
import com.keenwrite.constants.Constants;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.editors.common.Caret;
7
import com.keenwrite.events.TextEditorFocusEvent;
8
import com.keenwrite.io.MediaType;
9
import com.keenwrite.preferences.LocaleProperty;
10
import com.keenwrite.preferences.Workspace;
11
import com.keenwrite.processors.markdown.extensions.CaretExtension;
12
import com.keenwrite.spelling.impl.TextEditorSpeller;
13
import javafx.beans.binding.Bindings;
14
import javafx.beans.property.*;
15
import javafx.beans.value.ChangeListener;
16
import javafx.event.Event;
17
import javafx.scene.Node;
18
import javafx.scene.control.ContextMenu;
19
import javafx.scene.control.IndexRange;
20
import javafx.scene.control.MenuItem;
21
import javafx.scene.input.KeyEvent;
22
import javafx.scene.layout.BorderPane;
23
import org.fxmisc.flowless.VirtualizedScrollPane;
24
import org.fxmisc.richtext.StyleClassedTextArea;
25
import org.fxmisc.richtext.model.StyleSpans;
26
import org.fxmisc.undo.UndoManager;
27
import org.fxmisc.wellbehaved.event.EventPattern;
28
import org.fxmisc.wellbehaved.event.Nodes;
29
30
import java.io.File;
31
import java.nio.charset.Charset;
32
import java.text.BreakIterator;
33
import java.text.MessageFormat;
34
import java.util.*;
35
import java.util.function.Consumer;
36
import java.util.function.Supplier;
37
import java.util.regex.Pattern;
38
39
import static com.keenwrite.MainApp.keyDown;
40
import static com.keenwrite.constants.Constants.*;
41
import static com.keenwrite.events.StatusEvent.clue;
42
import static com.keenwrite.io.MediaType.TEXT_MARKDOWN;
43
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
44
import static com.keenwrite.preferences.AppKeys.*;
45
import static java.lang.Character.isWhitespace;
46
import static java.lang.String.format;
47
import static java.util.Collections.singletonList;
48
import static javafx.application.Platform.runLater;
49
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS;
50
import static javafx.scene.input.KeyCode.*;
51
import static javafx.scene.input.KeyCombination.*;
52
import static org.apache.commons.lang3.StringUtils.stripEnd;
53
import static org.apache.commons.lang3.StringUtils.stripStart;
54
import static org.fxmisc.richtext.model.StyleSpans.singleton;
55
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
56
import static org.fxmisc.wellbehaved.event.InputMap.consume;
57
58
/**
59
 * Responsible for editing Markdown documents.
60
 */
61
public final class MarkdownEditor extends BorderPane implements TextEditor {
62
  /**
63
   * Regular expression that matches the type of markup block. This is used
64
   * when Enter is pressed to continue the block environment.
65
   */
66
  private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile(
67
    "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" );
68
69
  private final Workspace mWorkspace;
70
71
  /**
72
   * The text editor.
73
   */
74
  private final StyleClassedTextArea mTextArea =
75
    new StyleClassedTextArea( false );
76
77
  /**
78
   * Wraps the text editor in scrollbars.
79
   */
80
  private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane =
81
    new VirtualizedScrollPane<>( mTextArea );
82
83
  /**
84
   * Tracks where the caret is located in this document. This offers observable
85
   * properties for caret position changes.
86
   */
87
  private final Caret mCaret = createCaret( mTextArea );
88
89
  /**
90
   * For spell checking the document upon load and whenever it changes.
91
   */
92
  private final TextEditorSpeller mSpeller = new TextEditorSpeller();
93
94
  /**
95
   * File being edited by this editor instance.
96
   */
97
  private File mFile;
98
99
  /**
100
   * Set to {@code true} upon text or caret position changes. Value is {@code
101
   * false} by default.
102
   */
103
  private final BooleanProperty mDirty = new SimpleBooleanProperty();
104
105
  /**
106
   * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if
107
   * either no encoding could be determined or this is a new (empty) file.
108
   */
109
  private final Charset mEncoding;
110
111
  /**
112
   * Tracks whether the in-memory definitions have changed with respect to the
113
   * persisted definitions.
114
   */
115
  private final BooleanProperty mModified = new SimpleBooleanProperty();
116
117
  public MarkdownEditor( final Workspace workspace ) {
118
    this( DOCUMENT_DEFAULT, workspace );
119
  }
120
121
  public MarkdownEditor( final File file, final Workspace workspace ) {
122
    mEncoding = open( mFile = file );
123
    mWorkspace = workspace;
124
125
    initTextArea( mTextArea );
126
    initStyle( mTextArea );
127
    initScrollPane( mScrollPane );
128
    initSpellchecker( mTextArea );
129
    initHotKeys();
130
    initUndoManager();
131
  }
132
133
  private void initTextArea( final StyleClassedTextArea textArea ) {
134
    textArea.setWrapText( true );
135
    textArea.requestFollowCaret();
136
    textArea.moveTo( 0 );
137
138
    textArea.textProperty().addListener( ( c, o, n ) -> {
139
      // Fire, regardless of whether the caret position has changed.
140
      mDirty.set( false );
141
142
      // Prevent the subsequent caret position change from raising dirty bits.
143
      mDirty.set( true );
144
    } );
145
146
    textArea.caretPositionProperty().addListener( ( c, o, n ) -> {
147
      // Fire when the caret position has changed and the text has not.
148
      mDirty.set( true );
149
      mDirty.set( false );
150
    } );
151
152
    textArea.focusedProperty().addListener( ( c, o, n ) -> {
153
      if( n != null && n ) {
154
        TextEditorFocusEvent.fire( this );
155
      }
156
    } );
157
  }
158
159
  private void initStyle( final StyleClassedTextArea textArea ) {
160
    textArea.getStyleClass().add( "markdown" );
161
162
    final var stylesheets = textArea.getStylesheets();
163
    stylesheets.add( getStylesheetPath( getLocale() ) );
164
165
    localeProperty().addListener( ( c, o, n ) -> {
166
      if( n != null ) {
167
        stylesheets.clear();
168
        stylesheets.add( getStylesheetPath( getLocale() ) );
169
      }
170
    } );
171
172
    fontNameProperty().addListener(
173
      ( c, o, n ) ->
174
        setFont( mTextArea, getFontName(), getFontSize() )
175
    );
176
177
    fontSizeProperty().addListener(
178
      ( c, o, n ) ->
179
        setFont( mTextArea, getFontName(), getFontSize() )
180
    );
181
182
    setFont( mTextArea, getFontName(), getFontSize() );
183
  }
184
185
  private void initScrollPane(
186
    final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) {
187
    scrollpane.setVbarPolicy( ALWAYS );
188
    setCenter( scrollpane );
189
  }
190
191
  private void initSpellchecker( final StyleClassedTextArea textarea ) {
192
    mSpeller.checkDocument( textarea );
193
    mSpeller.checkParagraphs( textarea );
194
  }
195
196
  private void initHotKeys() {
197
    addEventListener( keyPressed( ENTER ), this::onEnterPressed );
198
    addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut );
199
    addEventListener( keyPressed( TAB ), this::tab );
200
    addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab );
201
    addEventListener( keyPressed( ENTER, ALT_DOWN ), this::autofix );
202
  }
203
204
  private void initUndoManager() {
205
    final var undoManager = getUndoManager();
206
    final var markedPosition = undoManager.atMarkedPositionProperty();
207
208
    undoManager.forgetHistory();
209
    undoManager.mark();
210
    mModified.bind( Bindings.not( markedPosition ) );
211
  }
212
213
  @Override
214
  public void moveTo( final int offset ) {
215
    assert 0 <= offset && offset <= mTextArea.getLength();
216
217
    mTextArea.moveTo( offset );
218
    mTextArea.requestFollowCaret();
219
  }
220
221
  /**
222
   * Delegate the focus request to the text area itself.
223
   */
224
  @Override
225
  public void requestFocus() {
226
    mTextArea.requestFocus();
227
  }
228
229
  @Override
230
  public void setText( final String text ) {
231
    mTextArea.clear();
232
    mTextArea.appendText( text );
233
    mTextArea.getUndoManager().mark();
234
  }
235
236
  @Override
237
  public String getText() {
238
    return mTextArea.getText();
239
  }
240
241
  @Override
242
  public Charset getEncoding() {
243
    return mEncoding;
244
  }
245
246
  @Override
247
  public File getFile() {
248
    return mFile;
249
  }
250
251
  @Override
252
  public void rename( final File file ) {
253
    mFile = file;
254
  }
255
256
  @Override
257
  public void undo() {
258
    final var manager = getUndoManager();
259
    xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" );
260
  }
261
262
  @Override
263
  public void redo() {
264
    final var manager = getUndoManager();
265
    xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" );
266
  }
267
268
  /**
269
   * Performs an undo or redo action, if possible, otherwise displays an error
270
   * message to the user.
271
   *
272
   * @param ready  Answers whether the action can be executed.
273
   * @param action The action to execute.
274
   * @param key    The informational message key having a value to display if
275
   *               the {@link Supplier} is not ready.
276
   */
277
  private void xxdo(
278
    final Supplier<Boolean> ready, final Runnable action, final String key ) {
279
    if( ready.get() ) {
280
      action.run();
281
    }
282
    else {
283
      clue( key );
284
    }
285
  }
286
287
  @Override
288
  public void cut() {
289
    final var selected = mTextArea.getSelectedText();
290
291
    // Emulate selecting the current line by firing Home then Shift+Down Arrow.
292
    if( selected == null || selected.isEmpty() ) {
293
      // Note: mTextArea.selectLine() does not select empty lines.
294
      mTextArea.fireEvent( keyDown( HOME, false ) );
295
      mTextArea.fireEvent( keyDown( DOWN, true ) );
296
    }
297
298
    mTextArea.cut();
299
  }
300
301
  @Override
302
  public void copy() {
303
    mTextArea.copy();
304
  }
305
306
  @Override
307
  public void paste() {
308
    mTextArea.paste();
309
  }
310
311
  @Override
312
  public void selectAll() {
313
    mTextArea.selectAll();
314
  }
315
316
  @Override
317
  public void bold() {
318
    enwrap( "**" );
319
  }
320
321
  @Override
322
  public void italic() {
323
    enwrap( "*" );
324
  }
325
326
  @Override
327
  public void monospace() {
328
    enwrap( "`" );
329
  }
330
331
  @Override
332
  public void superscript() {
333
    enwrap( "^" );
334
  }
335
336
  @Override
337
  public void subscript() {
338
    enwrap( "~" );
339
  }
340
341
  @Override
342
  public void strikethrough() {
343
    enwrap( "~~" );
344
  }
345
346
  @Override
347
  public void blockquote() {
348
    block( "> " );
349
  }
350
351
  @Override
352
  public void code() {
353
    enwrap( "`" );
354
  }
355
356
  @Override
357
  public void fencedCodeBlock() {
358
    enwrap( "\n\n```\n", "\n```\n\n" );
359
  }
360
361
  @Override
362
  public void heading( final int level ) {
363
    final var hashes = new String( new char[ level ] ).replace( "\0", "#" );
364
    block( format( "%s ", hashes ) );
365
  }
366
367
  @Override
368
  public void unorderedList() {
369
    block( "* " );
370
  }
371
372
  @Override
373
  public void orderedList() {
374
    block( "1. " );
375
  }
376
377
  @Override
378
  public void horizontalRule() {
379
    block( format( "---%n%n" ) );
380
  }
381
382
  @Override
383
  public Node getNode() {
384
    return this;
385
  }
386
387
  @Override
388
  public ReadOnlyBooleanProperty modifiedProperty() {
389
    return mModified;
390
  }
391
392
  @Override
393
  public void clearModifiedProperty() {
394
    getUndoManager().mark();
395
  }
396
397
  @Override
398
  public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() {
399
    return mScrollPane;
400
  }
401
402
  @Override
403
  public StyleClassedTextArea getTextArea() {
404
    return mTextArea;
405
  }
406
407
  private final Map<String, IndexRange> mStyles = new HashMap<>();
408
409
  @Override
410
  public void stylize( final IndexRange range, final String style ) {
411
    final var began = range.getStart();
412
    final var ended = range.getEnd() + 1;
413
414
    assert 0 <= began && began <= ended;
415
    assert style != null;
416
417
    // TODO: Ensure spell check and find highlights can coexist.
418
//    final var spans = mTextArea.getStyleSpans( range );
419
//    System.out.println( "SPANS: " + spans );
420
421
//    final var spans = mTextArea.getStyleSpans( range );
422
//    mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style
423
//    ) );
424
425
//    final var builder = new StyleSpansBuilder<Collection<String>>();
426
//    builder.add( singleton( style ), range.getLength() + 1 );
427
//    mTextArea.setStyleSpans( began, builder.create() );
428
429
//    final var s = mTextArea.getStyleSpans( began, ended );
430
//    System.out.println( "STYLES: " +s );
431
432
    mStyles.put( style, range );
433
    mTextArea.setStyleClass( began, ended, style );
434
435
    // Ensure that whenever the user interacts with the text that the found
436
    // word will have its highlighting removed. The handler removes itself.
437
    // This won't remove the highlighting if the caret position moves by mouse.
438
    final var handler = mTextArea.getOnKeyPressed();
439
    mTextArea.setOnKeyPressed( ( event ) -> {
440
      mTextArea.setOnKeyPressed( handler );
441
      unstylize( style );
442
    } );
443
444
    //mTextArea.setStyleSpans(began, ended, s);
445
  }
446
447
  private static StyleSpans<Collection<String>> merge(
448
    StyleSpans<Collection<String>> spans, int len, String style ) {
449
    spans = spans.overlay(
450
      singleton( singletonList( style ), len ),
451
      ( bottomSpan, list ) -> {
452
        final List<String> l =
453
          new ArrayList<>( bottomSpan.size() + list.size() );
454
        l.addAll( bottomSpan );
455
        l.addAll( list );
456
        return l;
457
      } );
458
459
    return spans;
460
  }
461
462
  @Override
463
  public void unstylize( final String style ) {
464
    final var indexes = mStyles.remove( style );
465
    if( indexes != null ) {
466
      mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 );
467
    }
468
  }
469
470
  @Override
471
  public Caret getCaret() {
472
    return mCaret;
473
  }
474
475
  /**
476
   * A {@link Caret} instance is not directly coupled ot the GUI because
477
   * document processing does not always require interactive status bar
478
   * updates. This can happen when processing from the command-line. However,
479
   * the processors need the {@link Caret} instance to inject the caret
480
   * position into the document. Making the {@link CaretExtension} optional
481
   * would require more effort than using a {@link Caret} model that is
482
   * decoupled from GUI widgets.
483
   *
484
   * @param editor The text editor containing caret position information.
485
   * @return An instance of {@link Caret} that tracks the GUI caret position.
486
   */
487
  private Caret createCaret( final StyleClassedTextArea editor ) {
488
    return Caret
489
      .builder()
490
      .with( Caret.Mutator::setParagraph,
491
             () -> editor.currentParagraphProperty().getValue() )
492
      .with( Caret.Mutator::setParagraphs,
493
             () -> editor.getParagraphs().size() )
494
      .with( Caret.Mutator::setParaOffset,
495
             () -> editor.caretColumnProperty().getValue() )
496
      .with( Caret.Mutator::setTextOffset,
497
             () -> editor.caretPositionProperty().getValue() )
498
      .with( Caret.Mutator::setTextLength,
499
             () -> editor.lengthProperty().getValue() )
478500
      .build();
479501
  }
A src/main/java/com/keenwrite/events/CaretMovedEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.events;
3
4
import com.keenwrite.editors.common.Caret;
5
6
/**
7
 * Responsible for notifying when the caret has moved, which includes giving
8
 * focus to a different editor.
9
 */
10
public class CaretMovedEvent implements AppEvent {
11
  private final Caret mCaret;
12
13
  private CaretMovedEvent( final Caret caret ) {
14
    assert caret != null;
15
    mCaret = caret;
16
  }
17
18
  public static void fire( final Caret caret ) {
19
    new CaretMovedEvent( caret ).publish();
20
  }
21
22
  public Caret getCaret() {
23
    return mCaret;
24
  }
25
}
126
M src/main/java/com/keenwrite/events/CaretNavigationEvent.java
66
/**
77
 * Collates information about a caret event, which is typically triggered when
8
 * the user double-clicks in the {@link DocumentOutline}.
8
 * the user double-clicks in the {@link DocumentOutline}. This is an imperative
9
 * event, meaning that the position of the caret will be changed after this
10
 * event is handled. As opposed to a {@link CaretMovedEvent}, which provides
11
 * information about the caret after it has been moved.
912
 */
1013
public class CaretNavigationEvent implements AppEvent {
D src/main/java/com/keenwrite/preferences/KeyConfiguration.java
1
package com.keenwrite.preferences;
2
3
import com.keenwrite.cmdline.Arguments;
4
5
import java.io.File;
6
7
/**
8
 * Responsible for maintaining key-value pairs for user-defined setting
9
 * values. When processing a document, various settings are used to configure
10
 * the processing behaviour. This interface represents an abstraction that
11
 * can be used by the processors without having to depend on a specific
12
 * implementation, such as {@link Arguments} or a {@link Workspace}.
13
 */
14
public interface KeyConfiguration {
15
16
  /**
17
   * Returns a {@link String} value associated with the given {@link Key}.
18
   *
19
   * @param key The {@link Key} associated with a value.
20
   * @return The value associated with the given {@link Key}.
21
   */
22
  String getString( final Key key );
23
24
  /**
25
   * Returns a {@link Boolean} value associated with the given {@link Key}.
26
   *
27
   * @param key The {@link Key} associated with a value.
28
   * @return The value associated with the given {@link Key}.
29
   */
30
  boolean getBoolean( final Key key );
31
32
  /**
33
   * Returns an {@link Integer} value associated with the given {@link Key}.
34
   *
35
   * @param key The {@link Key} associated with a value.
36
   * @return The value associated with the given {@link Key}.
37
   */
38
  int getInteger( final Key key );
39
40
  /**
41
   * Returns a {@link Double} value associated with the given {@link Key}.
42
   *
43
   * @param key The {@link Key} associated with a value.
44
   * @return The value associated with the given {@link Key}.
45
   */
46
  double getDouble( final Key key );
47
48
  /**
49
   * Returns a {@link File} value associated with the given {@link Key}.
50
   *
51
   * @param key The {@link Key} associated with a value.
52
   * @return The value associated with the given {@link Key}.
53
   */
54
  File getFile( final Key key );
55
}
561
M src/main/java/com/keenwrite/preferences/SimpleTableControl.java
33
44
import com.dlsc.preferencesfx.formsfx.view.controls.SimpleControl;
5
import com.keenwrite.ui.table.AltTableCell;
5
import com.keenwrite.ui.cells.AltTableCell;
66
import javafx.beans.property.SimpleObjectProperty;
77
import javafx.event.ActionEvent;
M src/main/java/com/keenwrite/preferences/Workspace.java
22
package com.keenwrite.preferences;
33
4
import com.keenwrite.io.MediaType;
5
import com.keenwrite.sigils.PropertyKeyOperator;
6
import com.keenwrite.sigils.SigilKeyOperator;
7
import javafx.application.Platform;
8
import javafx.beans.property.*;
9
import javafx.collections.ObservableList;
10
11
import java.io.File;
12
import java.nio.file.Path;
13
import java.util.*;
14
import java.util.Map.Entry;
15
import java.util.function.BooleanSupplier;
16
import java.util.function.Function;
17
18
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
19
import static com.keenwrite.Launcher.getVersion;
20
import static com.keenwrite.constants.Constants.*;
21
import static com.keenwrite.events.StatusEvent.clue;
22
import static com.keenwrite.preferences.AppKeys.*;
23
import static java.util.Map.entry;
24
import static javafx.application.Platform.runLater;
25
import static javafx.collections.FXCollections.observableArrayList;
26
import static javafx.collections.FXCollections.observableSet;
27
28
/**
29
 * Responsible for defining behaviours for separate projects. A workspace has
30
 * the ability to save and restore a session, including the window dimensions,
31
 * tab setup, files, and user preferences.
32
 * <p>
33
 * The configuration must support hierarchical (nested) configuration nodes
34
 * to persist the user interface state. Although possible with a flat
35
 * configuration file, it's not nearly as simple or elegant.
36
 * </p>
37
 * <p>
38
 * Neither JSON nor HOCON support schema validation and versioning, which makes
39
 * XML the more suitable configuration file format. Schema validation and
40
 * versioning provide future-proofing and ease of reading and upgrading previous
41
 * versions of the configuration file.
42
 * </p>
43
 * <p>
44
 * Persistent preferences may be set directly by the user or indirectly by
45
 * the act of using the application.
46
 * </p>
47
 * <p>
48
 * Note the following definitions:
49
 * </p>
50
 * <dl>
51
 *   <dt>File</dt>
52
 *   <dd>References a file name (no path), path, or directory.</dd>
53
 *   <dt>Path</dt>
54
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
55
 *   <dt>Dir</dt>
56
 *   <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
57
 * </dl>
58
 */
59
public final class Workspace implements KeyConfiguration {
60
  /**
61
   * Main configuration values, single text strings.
62
   */
63
  private final Map<Key, Property<?>> mValues = Map.ofEntries(
64
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
65
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
66
67
    entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
68
69
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
70
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
71
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
72
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
73
74
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
75
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
76
    entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
77
    entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
78
79
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
80
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
81
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
82
83
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
84
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
85
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
86
    entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
87
88
    //@formatter:off
89
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
90
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
91
    entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
92
    entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
93
    entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
94
    entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
95
96
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
97
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
98
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
99
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
100
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
101
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
102
103
    entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
104
    entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
105
106
    entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ),
107
108
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
109
110
    entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
111
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
112
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
113
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
114
    //@formatter:on
115
  );
116
117
  /**
118
   * Sets of configuration values, all the same type (e.g., file names),
119
   * where the key name doesn't change per set.
120
   */
121
  private final Map<Key, SetProperty<?>> mSets = Map.ofEntries(
122
    entry(
123
      KEY_UI_RECENT_OPEN_PATH,
124
      createSetProperty( new HashSet<String>() )
125
    )
126
  );
127
128
  /**
129
   * Lists of configuration values, such as key-value pairs where both the
130
   * key name and the value must be preserved per list.
131
   */
132
  private final Map<Key, ListProperty<?>> mLists = Map.ofEntries(
133
    entry(
134
      KEY_DOC_META,
135
      createListProperty( new LinkedList<Entry<String, String>>() )
136
    )
137
  );
138
139
  /**
140
   * Helps instantiate {@link Property} instances for XML configuration items.
141
   */
142
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
143
    Map.of(
144
      LocaleProperty.class, LocaleProperty::parseLocale,
145
      SimpleBooleanProperty.class, Boolean::parseBoolean,
146
      SimpleIntegerProperty.class, Integer::parseInt,
147
      SimpleDoubleProperty.class, Double::parseDouble,
148
      SimpleFloatProperty.class, Float::parseFloat,
149
      SimpleStringProperty.class, String::new,
150
      SimpleObjectProperty.class, String::new,
151
      SkinProperty.class, String::new,
152
      FileProperty.class, File::new
153
    );
154
155
  /**
156
   * The asymmetry with respect to {@link #UNMARSHALL} is because most objects
157
   * can simply call {@link Object#toString()} to convert the value to a string.
158
   */
159
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
160
    Map.of(
161
      LocaleProperty.class, LocaleProperty::toLanguageTag
162
    );
163
164
  /**
165
   * Converts the given {@link Property} value to a string.
166
   *
167
   * @param property The {@link Property} to convert.
168
   * @return A string representation of the given property, or the empty
169
   * string if no conversion was possible.
170
   */
171
  private static String marshall( final Property<?> property ) {
172
    final var v = property.getValue();
173
174
    return v == null
175
      ? ""
176
      : MARSHALL
177
      .getOrDefault( property.getClass(), __ -> property.getValue() )
178
      .apply( v.toString() )
179
      .toString();
180
  }
181
182
  private static Object unmarshall(
183
    final Property<?> property, final Object configValue ) {
184
    final var v = configValue.toString();
185
186
    return UNMARSHALL
187
      .getOrDefault( property.getClass(), value -> property.getValue() )
188
      .apply( v );
189
  }
190
191
  /**
192
   * Creates an instance of {@link ObservableList} that is based on a
193
   * modifiable observable array list for the given items.
194
   *
195
   * @param items The items to wrap in an observable list.
196
   * @param <E>   The type of items to add to the list.
197
   * @return An observable property that can have its contents modified.
198
   */
199
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
200
    return new SimpleListProperty<>( observableArrayList( items ) );
201
  }
202
203
  private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
204
    return new SimpleSetProperty<>( observableSet( set ) );
205
  }
206
207
  private static <E> ListProperty<E> createListProperty( final List<E> list ) {
208
    return new SimpleListProperty<>( observableArrayList( list ) );
209
  }
210
211
  private static StringProperty asStringProperty( final String value ) {
212
    return new SimpleStringProperty( value );
213
  }
214
215
  private static BooleanProperty asBooleanProperty() {
216
    return new SimpleBooleanProperty();
217
  }
218
219
  /**
220
   * @param value Default value.
221
   */
222
  @SuppressWarnings( "SameParameterValue" )
223
  private static BooleanProperty asBooleanProperty( final boolean value ) {
224
    return new SimpleBooleanProperty( value );
225
  }
226
227
  /**
228
   * @param value Default value.
229
   */
230
  @SuppressWarnings( "SameParameterValue" )
231
  private static IntegerProperty asIntegerProperty( final int value ) {
232
    return new SimpleIntegerProperty( value );
233
  }
234
235
  /**
236
   * @param value Default value.
237
   */
238
  private static DoubleProperty asDoubleProperty( final double value ) {
239
    return new SimpleDoubleProperty( value );
240
  }
241
242
  /**
243
   * @param value Default value.
244
   */
245
  private static FileProperty asFileProperty( final File value ) {
246
    return new FileProperty( value );
247
  }
248
249
  /**
250
   * @param value Default value.
251
   */
252
  @SuppressWarnings( "SameParameterValue" )
253
  private static LocaleProperty asLocaleProperty( final Locale value ) {
254
    return new LocaleProperty( value );
255
  }
256
257
  /**
258
   * @param value Default value.
259
   */
260
  @SuppressWarnings( "SameParameterValue" )
261
  private static SkinProperty asSkinProperty( final String value ) {
262
    return new SkinProperty( value );
263
  }
264
265
  /**
266
   * Creates a new {@link Workspace} that will attempt to load the users'
267
   * preferences. If the configuration file cannot be loaded, the workspace
268
   * settings returns default values.
269
   */
270
  public Workspace() {
271
    load();
272
  }
273
274
  /**
275
   * Attempts to load the app's configuration file.
276
   */
277
  private void load() {
278
    final var store = createXmlStore();
279
    store.load( FILE_PREFERENCES );
280
281
    mValues.keySet().forEach( key -> {
282
      try {
283
        final var storeValue = store.getValue( key );
284
        final var property = valuesProperty( key );
285
286
        property.setValue( unmarshall( property, storeValue ) );
287
      } catch( final NoSuchElementException ignored ) {
288
        // When no configuration (item), use the default value.
289
      }
290
    } );
291
292
    mSets.keySet().forEach( key -> {
293
      final var set = store.getSet( key );
294
      final SetProperty<String> property = setsProperty( key );
295
296
      property.setValue( observableSet( set ) );
297
    } );
298
299
    mLists.keySet().forEach( key -> {
300
      final var map = store.getMap( key );
301
      final ListProperty<Entry<String, String>> property = listsProperty( key );
302
      final var list = map
303
        .entrySet()
304
        .stream()
305
        .toList();
306
307
      property.setValue( observableArrayList( list ) );
308
    } );
309
  }
310
311
  /**
312
   * Saves the current workspace.
313
   */
314
  public void save() {
315
    final var store = createXmlStore();
316
317
    try {
318
      // Update the string values to include the application version.
319
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
320
321
      mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) );
322
      mSets.forEach( store::setSet );
323
      mLists.forEach( store::setMap );
324
325
      store.save( FILE_PREFERENCES );
326
    } catch( final Exception ex ) {
327
      clue( ex );
328
    }
329
  }
330
331
  /**
332
   * Returns a value that represents a setting in the application that the user
333
   * may configure, either directly or indirectly.
334
   *
335
   * @param key The reference to the users' preference stored in deference
336
   *            of app reëntrance.
337
   * @return An observable property to be persisted.
338
   */
339
  @SuppressWarnings( "unchecked" )
340
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
341
    assert key != null;
342
    return (U) mValues.get( key );
343
  }
344
345
  /**
346
   * Returns a set of values that represent a setting in the application that
347
   * the user may configure, either directly or indirectly. The property
348
   * returned is backed by a {@link Set}.
349
   *
350
   * @param key The {@link Key} associated with a preference value.
351
   * @return An observable property to be persisted.
352
   */
353
  @SuppressWarnings( "unchecked" )
354
  public <T> SetProperty<T> setsProperty( final Key key ) {
355
    assert key != null;
356
    return (SetProperty<T>) mSets.get( key );
357
  }
358
359
  /**
360
   * Returns a list of values that represent a setting in the application that
361
   * the user may configure, either directly or indirectly. The property
362
   * returned is backed by a mutable {@link List}.
363
   *
364
   * @param key The {@link Key} associated with a preference value.
365
   * @return An observable property to be persisted.
366
   */
367
  @SuppressWarnings( "unchecked" )
368
  public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
369
    assert key != null;
370
    return (ListProperty<Entry<K, V>>) mLists.get( key );
371
  }
372
373
  /**
374
   * Returns the {@link String} {@link Property} associated with the given
375
   * {@link Key} from the internal list of preference values. The caller
376
   * must be sure that the given {@link Key} is associated with a {@link File}
377
   * {@link Property}.
378
   *
379
   * @param key The {@link Key} associated with a preference value.
380
   * @return The value associated with the given {@link Key}.
381
   */
382
  public StringProperty stringProperty( final Key key ) {
383
    assert key != null;
384
    return valuesProperty( key );
385
  }
386
387
  /**
388
   * Returns the {@link Boolean} {@link Property} associated with the given
389
   * {@link Key} from the internal list of preference values. The caller
390
   * must be sure that the given {@link Key} is associated with a {@link File}
391
   * {@link Property}.
392
   *
393
   * @param key The {@link Key} associated with a preference value.
394
   * @return The value associated with the given {@link Key}.
395
   */
396
  public BooleanProperty booleanProperty( final Key key ) {
397
    assert key != null;
398
    return valuesProperty( key );
399
  }
400
401
  /**
402
   * Returns the {@link Integer} {@link Property} associated with the given
403
   * {@link Key} from the internal list of preference values. The caller
404
   * must be sure that the given {@link Key} is associated with a {@link File}
405
   * {@link Property}.
406
   *
407
   * @param key The {@link Key} associated with a preference value.
408
   * @return The value associated with the given {@link Key}.
409
   */
410
  public IntegerProperty integerProperty( final Key key ) {
411
    assert key != null;
412
    return valuesProperty( key );
413
  }
414
415
  /**
416
   * Returns the {@link Double} {@link Property} associated with the given
417
   * {@link Key} from the internal list of preference values. The caller
418
   * must be sure that the given {@link Key} is associated with a {@link File}
419
   * {@link Property}.
420
   *
421
   * @param key The {@link Key} associated with a preference value.
422
   * @return The value associated with the given {@link Key}.
423
   */
424
  public DoubleProperty doubleProperty( final Key key ) {
425
    assert key != null;
426
    return valuesProperty( key );
427
  }
428
429
  /**
430
   * Returns the {@link File} {@link Property} associated with the given
431
   * {@link Key} from the internal list of preference values. The caller
432
   * must be sure that the given {@link Key} is associated with a {@link File}
433
   * {@link Property}.
434
   *
435
   * @param key The {@link Key} associated with a preference value.
436
   * @return The value associated with the given {@link Key}.
437
   */
438
  public ObjectProperty<File> fileProperty( final Key key ) {
439
    assert key != null;
440
    return valuesProperty( key );
441
  }
442
443
  /**
444
   * Returns the {@link Locale} {@link Property} associated with the given
445
   * {@link Key} from the internal list of preference values. The caller
446
   * must be sure that the given {@link Key} is associated with a {@link File}
447
   * {@link Property}.
448
   *
449
   * @param key The {@link Key} associated with a preference value.
450
   * @return The value associated with the given {@link Key}.
451
   */
452
  public LocaleProperty localeProperty( final Key key ) {
453
    assert key != null;
454
    return valuesProperty( key );
455
  }
456
457
  public ObjectProperty<String> skinProperty( final Key key ) {
458
    assert key != null;
459
    return valuesProperty( key );
460
  }
461
462
  @Override
463
  public String getString( final Key key ) {
464
    assert key != null;
465
    return stringProperty( key ).get();
466
  }
467
468
  /**
469
   * Returns the {@link Boolean} preference value associated with the given
470
   * {@link Key}. The caller must be sure that the given {@link Key} is
471
   * associated with a value that matches the return type.
472
   *
473
   * @param key The {@link Key} associated with a preference value.
474
   * @return The value associated with the given {@link Key}.
475
   */
476
  @Override
477
  public boolean getBoolean( final Key key ) {
478
    assert key != null;
479
    return booleanProperty( key ).get();
480
  }
481
482
  /**
483
   * Returns the {@link Integer} preference value associated with the given
484
   * {@link Key}. The caller must be sure that the given {@link Key} is
485
   * associated with a value that matches the return type.
486
   *
487
   * @param key The {@link Key} associated with a preference value.
488
   * @return The value associated with the given {@link Key}.
489
   */
490
  @Override
491
  public int getInteger( final Key key ) {
492
    assert key != null;
493
    return integerProperty( key ).get();
494
  }
495
496
  /**
497
   * Returns the {@link Double} preference value associated with the given
498
   * {@link Key}. The caller must be sure that the given {@link Key} is
499
   * associated with a value that matches the return type.
500
   *
501
   * @param key The {@link Key} associated with a preference value.
502
   * @return The value associated with the given {@link Key}.
503
   */
504
  @Override
505
  public double getDouble( final Key key ) {
506
    assert key != null;
507
    return doubleProperty( key ).get();
508
  }
509
510
  /**
511
   * Returns the {@link File} preference value associated with the given
512
   * {@link Key}. The caller must be sure that the given {@link Key} is
513
   * associated with a value that matches the return type.
514
   *
515
   * @param key The {@link Key} associated with a preference value.
516
   * @return The value associated with the given {@link Key}.
517
   */
518
  @Override
519
  public File getFile( final Key key ) {
520
    assert key != null;
521
    return fileProperty( key ).get();
522
  }
523
524
  /**
525
   * Returns the language locale setting for the
526
   * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
527
   *
528
   * @return The user's current locale setting.
529
   */
530
  public Locale getLocale() {
531
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
532
  }
533
534
  public SigilKeyOperator createDefinitionKeyOperator() {
535
    final var began = getString( KEY_DEF_DELIM_BEGAN );
536
    final var ended = getString( KEY_DEF_DELIM_ENDED );
537
538
    return new SigilKeyOperator( began, ended );
539
  }
540
541
  public SigilKeyOperator createPropertyKeyOperator() {
542
    return new PropertyKeyOperator();
543
  }
544
545
  /**
546
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
547
   * providing a value of {@code true} for the {@link BooleanSupplier} to
548
   * indicate the property changes always take effect.
549
   *
550
   * @param key      The value to bind to the internal key property.
551
   * @param property The external property value that sets the internal value.
552
   */
553
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
554
    assert key != null;
555
    assert property != null;
556
557
    listen( key, property, () -> true );
558
  }
559
560
  /**
561
   * Binds a read-only property to a value in the preferences. This allows
562
   * user interface properties to change and the preferences will be
563
   * synchronized automatically.
564
   * <p>
565
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
566
   * application window states are finished before assessing whether property
567
   * changes should be applied. Without this, exiting the application while the
568
   * window is maximized would persist the window's maximum dimensions,
569
   * preventing restoration to its prior, non-maximum size.
570
   *
571
   * @param key      The value to bind to the internal key property.
572
   * @param property The external property value that sets the internal value.
573
   * @param enabled  Indicates whether property changes should be applied.
574
   */
575
  public <T> void listen(
576
    final Key key,
577
    final ReadOnlyProperty<T> property,
578
    final BooleanSupplier enabled ) {
579
    assert key != null;
580
    assert property != null;
581
    assert enabled != null;
582
583
    property.addListener(
584
      ( c, o, n ) -> runLater( () -> {
585
        if( enabled.getAsBoolean() ) {
586
          valuesProperty( key ).setValue( n );
587
        }
588
      } )
589
    );
590
  }
591
592
  /**
593
   * Returns the sigil operator for the given {@link MediaType}.
594
   *
595
   * @param mediaType The type of file being edited.
596
   */
597
  public SigilKeyOperator createSigilOperator( final MediaType mediaType ) {
598
    assert mediaType != null;
599
600
    return mediaType == MediaType.TEXT_PROPERTIES
601
      ? createPropertyKeyOperator()
602
      : createDefinitionKeyOperator();
603
  }
604
605
  /**
606
   * Returns the sigil operator for the given {@link Path}.
607
   *
608
   * @param path The type of file being edited, from its extension.
609
   */
610
  public SigilKeyOperator createSigilOperator( final Path path ) {
611
    assert path != null;
612
613
    return createSigilOperator( MediaType.valueFrom( path ) );
4
import javafx.application.Platform;
5
import javafx.beans.property.*;
6
import javafx.collections.ObservableList;
7
8
import java.io.File;
9
import java.nio.file.Path;
10
import java.util.*;
11
import java.util.Map.Entry;
12
import java.util.function.BooleanSupplier;
13
import java.util.function.Function;
14
15
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
16
import static com.keenwrite.Launcher.getVersion;
17
import static com.keenwrite.constants.Constants.*;
18
import static com.keenwrite.events.StatusEvent.clue;
19
import static com.keenwrite.preferences.AppKeys.*;
20
import static java.util.Map.entry;
21
import static javafx.application.Platform.runLater;
22
import static javafx.collections.FXCollections.observableArrayList;
23
import static javafx.collections.FXCollections.observableSet;
24
25
/**
26
 * Responsible for defining behaviours for separate projects. A workspace has
27
 * the ability to save and restore a session, including the window dimensions,
28
 * tab setup, files, and user preferences.
29
 * <p>
30
 * The configuration must support hierarchical (nested) configuration nodes
31
 * to persist the user interface state. Although possible with a flat
32
 * configuration file, it's not nearly as simple or elegant.
33
 * </p>
34
 * <p>
35
 * Neither JSON nor HOCON support schema validation and versioning, which makes
36
 * XML the more suitable configuration file format. Schema validation and
37
 * versioning provide future-proofing and ease of reading and upgrading previous
38
 * versions of the configuration file.
39
 * </p>
40
 * <p>
41
 * Persistent preferences may be set directly by the user or indirectly by
42
 * the act of using the application.
43
 * </p>
44
 * <p>
45
 * Note the following definitions:
46
 * </p>
47
 * <dl>
48
 *   <dt>File</dt>
49
 *   <dd>References a file name (no path), path, or directory.</dd>
50
 *   <dt>Path</dt>
51
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
52
 *   <dt>Dir</dt>
53
 *   <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
54
 * </dl>
55
 */
56
public final class Workspace {
57
58
  /**
59
   * Main configuration values, single text strings.
60
   */
61
  private final Map<Key, Property<?>> mValues = Map.ofEntries(
62
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
63
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
64
65
    entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
66
67
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
68
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
69
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
70
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
71
72
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
73
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
74
    entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
75
    entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
76
77
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
78
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
79
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
80
81
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
82
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
83
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
84
    entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
85
86
    //@formatter:off
87
    entry( KEY_UI_FONT_EDITOR_NAME, asStringProperty( FONT_NAME_EDITOR_DEFAULT ) ),
88
    entry( KEY_UI_FONT_EDITOR_SIZE, asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT ) ),
89
    entry( KEY_UI_FONT_PREVIEW_NAME, asStringProperty( FONT_NAME_PREVIEW_DEFAULT ) ),
90
    entry( KEY_UI_FONT_PREVIEW_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT ) ),
91
    entry( KEY_UI_FONT_PREVIEW_MONO_NAME, asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT ) ),
92
    entry( KEY_UI_FONT_PREVIEW_MONO_SIZE, asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) ),
93
94
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
95
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
96
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
97
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
98
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
99
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
100
101
    entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
102
    entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
103
104
    entry( KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT ) ),
105
106
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
107
108
    entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
109
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
110
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
111
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
112
    //@formatter:on
113
  );
114
115
  /**
116
   * Sets of configuration values, all the same type (e.g., file names),
117
   * where the key name doesn't change per set.
118
   */
119
  private final Map<Key, SetProperty<?>> mSets = Map.ofEntries(
120
    entry(
121
      KEY_UI_RECENT_OPEN_PATH,
122
      createSetProperty( new HashSet<String>() )
123
    )
124
  );
125
126
  /**
127
   * Lists of configuration values, such as key-value pairs where both the
128
   * key name and the value must be preserved per list.
129
   */
130
  private final Map<Key, ListProperty<?>> mLists = Map.ofEntries(
131
    entry(
132
      KEY_DOC_META,
133
      createListProperty( new LinkedList<Entry<String, String>>() )
134
    )
135
  );
136
137
  /**
138
   * Helps instantiate {@link Property} instances for XML configuration items.
139
   */
140
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
141
    Map.of(
142
      LocaleProperty.class, LocaleProperty::parseLocale,
143
      SimpleBooleanProperty.class, Boolean::parseBoolean,
144
      SimpleIntegerProperty.class, Integer::parseInt,
145
      SimpleDoubleProperty.class, Double::parseDouble,
146
      SimpleFloatProperty.class, Float::parseFloat,
147
      SimpleStringProperty.class, String::new,
148
      SimpleObjectProperty.class, String::new,
149
      SkinProperty.class, String::new,
150
      FileProperty.class, File::new
151
    );
152
153
  /**
154
   * The asymmetry with respect to {@link #UNMARSHALL} is because most objects
155
   * can simply call {@link Object#toString()} to convert the value to a string.
156
   */
157
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
158
    Map.of(
159
      LocaleProperty.class, LocaleProperty::toLanguageTag
160
    );
161
162
  /**
163
   * Converts the given {@link Property} value to a string.
164
   *
165
   * @param property The {@link Property} to convert.
166
   * @return A string representation of the given property, or the empty
167
   * string if no conversion was possible.
168
   */
169
  private static String marshall( final Property<?> property ) {
170
    final var v = property.getValue();
171
172
    return v == null
173
      ? ""
174
      : MARSHALL
175
      .getOrDefault( property.getClass(), __ -> property.getValue() )
176
      .apply( v.toString() )
177
      .toString();
178
  }
179
180
  private static Object unmarshall(
181
    final Property<?> property, final Object configValue ) {
182
    final var v = configValue.toString();
183
184
    return UNMARSHALL
185
      .getOrDefault( property.getClass(), value -> property.getValue() )
186
      .apply( v );
187
  }
188
189
  /**
190
   * Creates an instance of {@link ObservableList} that is based on a
191
   * modifiable observable array list for the given items.
192
   *
193
   * @param items The items to wrap in an observable list.
194
   * @param <E>   The type of items to add to the list.
195
   * @return An observable property that can have its contents modified.
196
   */
197
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
198
    return new SimpleListProperty<>( observableArrayList( items ) );
199
  }
200
201
  private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
202
    return new SimpleSetProperty<>( observableSet( set ) );
203
  }
204
205
  private static <E> ListProperty<E> createListProperty( final List<E> list ) {
206
    return new SimpleListProperty<>( observableArrayList( list ) );
207
  }
208
209
  private static StringProperty asStringProperty( final String value ) {
210
    return new SimpleStringProperty( value );
211
  }
212
213
  private static BooleanProperty asBooleanProperty() {
214
    return new SimpleBooleanProperty();
215
  }
216
217
  /**
218
   * @param value Default value.
219
   */
220
  @SuppressWarnings( "SameParameterValue" )
221
  private static BooleanProperty asBooleanProperty( final boolean value ) {
222
    return new SimpleBooleanProperty( value );
223
  }
224
225
  /**
226
   * @param value Default value.
227
   */
228
  @SuppressWarnings( "SameParameterValue" )
229
  private static IntegerProperty asIntegerProperty( final int value ) {
230
    return new SimpleIntegerProperty( value );
231
  }
232
233
  /**
234
   * @param value Default value.
235
   */
236
  private static DoubleProperty asDoubleProperty( final double value ) {
237
    return new SimpleDoubleProperty( value );
238
  }
239
240
  /**
241
   * @param value Default value.
242
   */
243
  private static FileProperty asFileProperty( final File value ) {
244
    return new FileProperty( value );
245
  }
246
247
  /**
248
   * @param value Default value.
249
   */
250
  @SuppressWarnings( "SameParameterValue" )
251
  private static LocaleProperty asLocaleProperty( final Locale value ) {
252
    return new LocaleProperty( value );
253
  }
254
255
  /**
256
   * @param value Default value.
257
   */
258
  @SuppressWarnings( "SameParameterValue" )
259
  private static SkinProperty asSkinProperty( final String value ) {
260
    return new SkinProperty( value );
261
  }
262
263
  /**
264
   * Creates a new {@link Workspace} that will attempt to load the users'
265
   * preferences. If the configuration file cannot be loaded, the workspace
266
   * settings returns default values.
267
   */
268
  public Workspace() {
269
    load();
270
  }
271
272
  /**
273
   * Attempts to load the app's configuration file.
274
   */
275
  private void load() {
276
    final var store = createXmlStore();
277
    store.load( FILE_PREFERENCES );
278
279
    mValues.keySet().forEach( key -> {
280
      try {
281
        final var storeValue = store.getValue( key );
282
        final var property = valuesProperty( key );
283
284
        property.setValue( unmarshall( property, storeValue ) );
285
      } catch( final NoSuchElementException ignored ) {
286
        // When no configuration (item), use the default value.
287
      }
288
    } );
289
290
    mSets.keySet().forEach( key -> {
291
      final var set = store.getSet( key );
292
      final SetProperty<String> property = setsProperty( key );
293
294
      property.setValue( observableSet( set ) );
295
    } );
296
297
    mLists.keySet().forEach( key -> {
298
      final var map = store.getMap( key );
299
      final ListProperty<Entry<String, String>> property = listsProperty( key );
300
      final var list = map
301
        .entrySet()
302
        .stream()
303
        .toList();
304
305
      property.setValue( observableArrayList( list ) );
306
    } );
307
  }
308
309
  /**
310
   * Saves the current workspace.
311
   */
312
  public void save() {
313
    final var store = createXmlStore();
314
315
    try {
316
      // Update the string values to include the application version.
317
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
318
319
      mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) );
320
      mSets.forEach( store::setSet );
321
      mLists.forEach( store::setMap );
322
323
      store.save( FILE_PREFERENCES );
324
    } catch( final Exception ex ) {
325
      clue( ex );
326
    }
327
  }
328
329
  /**
330
   * Returns a value that represents a setting in the application that the user
331
   * may configure, either directly or indirectly.
332
   *
333
   * @param key The reference to the users' preference stored in deference
334
   *            of app reëntrance.
335
   * @return An observable property to be persisted.
336
   */
337
  @SuppressWarnings( "unchecked" )
338
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
339
    assert key != null;
340
    return (U) mValues.get( key );
341
  }
342
343
  /**
344
   * Returns a set of values that represent a setting in the application that
345
   * the user may configure, either directly or indirectly. The property
346
   * returned is backed by a {@link Set}.
347
   *
348
   * @param key The {@link Key} associated with a preference value.
349
   * @return An observable property to be persisted.
350
   */
351
  @SuppressWarnings( "unchecked" )
352
  public <T> SetProperty<T> setsProperty( final Key key ) {
353
    assert key != null;
354
    return (SetProperty<T>) mSets.get( key );
355
  }
356
357
  /**
358
   * Returns a list of values that represent a setting in the application that
359
   * the user may configure, either directly or indirectly. The property
360
   * returned is backed by a mutable {@link List}.
361
   *
362
   * @param key The {@link Key} associated with a preference value.
363
   * @return An observable property to be persisted.
364
   */
365
  @SuppressWarnings( "unchecked" )
366
  public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
367
    assert key != null;
368
    return (ListProperty<Entry<K, V>>) mLists.get( key );
369
  }
370
371
  /**
372
   * Returns the {@link String} {@link Property} associated with the given
373
   * {@link Key} from the internal list of preference values. The caller
374
   * must be sure that the given {@link Key} is associated with a {@link File}
375
   * {@link Property}.
376
   *
377
   * @param key The {@link Key} associated with a preference value.
378
   * @return The value associated with the given {@link Key}.
379
   */
380
  public StringProperty stringProperty( final Key key ) {
381
    assert key != null;
382
    return valuesProperty( key );
383
  }
384
385
  /**
386
   * Returns the {@link Boolean} {@link Property} associated with the given
387
   * {@link Key} from the internal list of preference values. The caller
388
   * must be sure that the given {@link Key} is associated with a {@link File}
389
   * {@link Property}.
390
   *
391
   * @param key The {@link Key} associated with a preference value.
392
   * @return The value associated with the given {@link Key}.
393
   */
394
  public BooleanProperty booleanProperty( final Key key ) {
395
    assert key != null;
396
    return valuesProperty( key );
397
  }
398
399
  /**
400
   * Returns the {@link Integer} {@link Property} associated with the given
401
   * {@link Key} from the internal list of preference values. The caller
402
   * must be sure that the given {@link Key} is associated with a {@link File}
403
   * {@link Property}.
404
   *
405
   * @param key The {@link Key} associated with a preference value.
406
   * @return The value associated with the given {@link Key}.
407
   */
408
  public IntegerProperty integerProperty( final Key key ) {
409
    assert key != null;
410
    return valuesProperty( key );
411
  }
412
413
  /**
414
   * Returns the {@link Double} {@link Property} associated with the given
415
   * {@link Key} from the internal list of preference values. The caller
416
   * must be sure that the given {@link Key} is associated with a {@link File}
417
   * {@link Property}.
418
   *
419
   * @param key The {@link Key} associated with a preference value.
420
   * @return The value associated with the given {@link Key}.
421
   */
422
  public DoubleProperty doubleProperty( final Key key ) {
423
    assert key != null;
424
    return valuesProperty( key );
425
  }
426
427
  /**
428
   * Returns the {@link File} {@link Property} associated with the given
429
   * {@link Key} from the internal list of preference values. The caller
430
   * must be sure that the given {@link Key} is associated with a {@link File}
431
   * {@link Property}.
432
   *
433
   * @param key The {@link Key} associated with a preference value.
434
   * @return The value associated with the given {@link Key}.
435
   */
436
  public ObjectProperty<File> fileProperty( final Key key ) {
437
    assert key != null;
438
    return valuesProperty( key );
439
  }
440
441
  /**
442
   * Returns the {@link Locale} {@link Property} associated with the given
443
   * {@link Key} from the internal list of preference values. The caller
444
   * must be sure that the given {@link Key} is associated with a {@link File}
445
   * {@link Property}.
446
   *
447
   * @param key The {@link Key} associated with a preference value.
448
   * @return The value associated with the given {@link Key}.
449
   */
450
  public LocaleProperty localeProperty( final Key key ) {
451
    assert key != null;
452
    return valuesProperty( key );
453
  }
454
455
  public ObjectProperty<String> skinProperty( final Key key ) {
456
    assert key != null;
457
    return valuesProperty( key );
458
  }
459
460
  public String getString( final Key key ) {
461
    assert key != null;
462
    return stringProperty( key ).get();
463
  }
464
465
  /**
466
   * Returns the {@link Boolean} preference value associated with the given
467
   * {@link Key}. The caller must be sure that the given {@link Key} is
468
   * associated with a value that matches the return type.
469
   *
470
   * @param key The {@link Key} associated with a preference value.
471
   * @return The value associated with the given {@link Key}.
472
   */
473
  public boolean getBoolean( final Key key ) {
474
    assert key != null;
475
    return booleanProperty( key ).get();
476
  }
477
478
  /**
479
   * Returns the {@link Integer} preference value associated with the given
480
   * {@link Key}. The caller must be sure that the given {@link Key} is
481
   * associated with a value that matches the return type.
482
   *
483
   * @param key The {@link Key} associated with a preference value.
484
   * @return The value associated with the given {@link Key}.
485
   */
486
  public int getInteger( final Key key ) {
487
    assert key != null;
488
    return integerProperty( key ).get();
489
  }
490
491
  /**
492
   * Returns the {@link Double} preference value associated with the given
493
   * {@link Key}. The caller must be sure that the given {@link Key} is
494
   * associated with a value that matches the return type.
495
   *
496
   * @param key The {@link Key} associated with a preference value.
497
   * @return The value associated with the given {@link Key}.
498
   */
499
  public double getDouble( final Key key ) {
500
    assert key != null;
501
    return doubleProperty( key ).get();
502
  }
503
504
  /**
505
   * Returns the {@link File} preference value associated with the given
506
   * {@link Key}. The caller must be sure that the given {@link Key} is
507
   * associated with a value that matches the return type.
508
   *
509
   * @param key The {@link Key} associated with a preference value.
510
   * @return The value associated with the given {@link Key}.
511
   */
512
  public File getFile( final Key key ) {
513
    assert key != null;
514
    return fileProperty( key ).get();
515
  }
516
517
  /**
518
   * Returns the language locale setting for the
519
   * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
520
   *
521
   * @return The user's current locale setting.
522
   */
523
  public Locale getLocale() {
524
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
525
  }
526
527
  @SuppressWarnings( "unchecked" )
528
  public <K, V> Map<K, V> getMetadata() {
529
    final var metadata = listsProperty( KEY_DOC_META );
530
    final var map = new HashMap<K, V>( metadata.size() );
531
532
    metadata.forEach(
533
      entry -> map.put( (K) entry.getKey(), (V) entry.getValue() )
534
    );
535
536
    return map;
537
  }
538
539
  public Path getThemePath() {
540
    final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
541
    final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
542
543
    return Path.of( dir.toString(), name );
544
  }
545
546
  /**
547
   * Delegates to {@link #listen(Key, ReadOnlyProperty, BooleanSupplier)},
548
   * providing a value of {@code true} for the {@link BooleanSupplier} to
549
   * indicate the property changes always take effect.
550
   *
551
   * @param key      The value to bind to the internal key property.
552
   * @param property The external property value that sets the internal value.
553
   */
554
  public <T> void listen( final Key key, final ReadOnlyProperty<T> property ) {
555
    assert key != null;
556
    assert property != null;
557
558
    listen( key, property, () -> true );
559
  }
560
561
  /**
562
   * Binds a read-only property to a value in the preferences. This allows
563
   * user interface properties to change and the preferences will be
564
   * synchronized automatically.
565
   * <p>
566
   * This calls {@link Platform#runLater(Runnable)} to ensure that all pending
567
   * application window states are finished before assessing whether property
568
   * changes should be applied. Without this, exiting the application while the
569
   * window is maximized would persist the window's maximum dimensions,
570
   * preventing restoration to its prior, non-maximum size.
571
   *
572
   * @param key      The value to bind to the internal key property.
573
   * @param property The external property value that sets the internal value.
574
   * @param enabled  Indicates whether property changes should be applied.
575
   */
576
  public <T> void listen(
577
    final Key key,
578
    final ReadOnlyProperty<T> property,
579
    final BooleanSupplier enabled ) {
580
    assert key != null;
581
    assert property != null;
582
    assert enabled != null;
583
584
    property.addListener(
585
      ( c, o, n ) -> runLater( () -> {
586
        if( enabled.getAsBoolean() ) {
587
          valuesProperty( key ).setValue( n );
588
        }
589
      } )
590
    );
614591
  }
615592
M src/main/java/com/keenwrite/processors/PdfProcessor.java
99
import static com.keenwrite.events.StatusEvent.clue;
1010
import static com.keenwrite.io.MediaType.TEXT_XML;
11
import static com.keenwrite.preferences.AppKeys.*;
1211
import static com.keenwrite.typesetting.Typesetter.Mutator;
1312
import static java.nio.file.Files.deleteIfExists;
...
3736
    try {
3837
      clue( "Main.status.typeset.create" );
39
      final var workspace = mContext.getWorkspace();
38
      final var context = mContext;
4039
      final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE );
4140
      final var typesetter = Typesetter
4241
        .builder()
43
        .with( Mutator::setInputPath,
44
               writeString( document, xhtml ) )
45
        .with( Mutator::setOutputPath,
46
               mContext.getOutputPath() )
47
        .with( Mutator::setThemePath,
48
               workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ) )
49
        .with( Mutator::setThemeName,
50
               workspace.getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ) )
51
        .with( Mutator::setAutoclean,
52
               workspace.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) )
42
        .with( Mutator::setInputPath, writeString( document, xhtml ) )
43
        .with( Mutator::setOutputPath, context.getOutputPath() )
44
        .with( Mutator::setThemePath, context.getThemePath() )
45
        .with( Mutator::setAutoClean, context.getAutoClean() )
5346
        .build();
5447
M src/main/java/com/keenwrite/processors/ProcessorContext.java
22
package com.keenwrite.processors;
33
4
import com.keenwrite.Caret;
54
import com.keenwrite.ExportFormat;
5
import com.keenwrite.collections.InterpolatingMap;
66
import com.keenwrite.constants.Constants;
7
import com.keenwrite.editors.common.Caret;
78
import com.keenwrite.io.FileType;
8
import com.keenwrite.preferences.Workspace;
9
import com.keenwrite.sigils.PropertyKeyOperator;
910
import com.keenwrite.sigils.SigilKeyOperator;
1011
import com.keenwrite.util.GenericBuilder;
11
import com.keenwrite.collections.InterpolatingMap;
12
import org.renjin.repackaged.guava.base.Splitter;
1213
1314
import java.io.File;
1415
import java.nio.file.Path;
16
import java.util.HashMap;
17
import java.util.Locale;
1518
import java.util.Map;
1619
import java.util.concurrent.Callable;
1720
import java.util.function.Supplier;
1821
19
import static com.keenwrite.AbstractFileFactory.lookup;
20
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
22
import static com.keenwrite.constants.Constants.*;
23
import static com.keenwrite.io.FileType.UNKNOWN;
24
import static com.keenwrite.io.MediaType.TEXT_PROPERTIES;
25
import static com.keenwrite.io.MediaType.valueFrom;
26
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
2127
2228
/**
2329
 * Provides a context for configuring a chain of {@link Processor} instances.
2430
 */
2531
public final class ProcessorContext {
2632
2733
  private final Mutator mMutator;
34
35
  /**
36
   * Determines the file type from the path extension. This should only be
37
   * called when it is known that the file type won't be a definition file
38
   * (e.g., YAML or other definition source), but rather an editable file
39
   * (e.g., Markdown, R Markdown, etc.).
40
   *
41
   * @param path The path with a file name extension.
42
   * @return The FileType for the given path.
43
   */
44
  private static FileType lookup( final Path path ) {
45
    assert path != null;
46
47
    final var prefix = GLOB_PREFIX_FILE;
48
    final var keys = sSettings.getKeys( prefix );
49
50
    var found = false;
51
    var fileType = UNKNOWN;
52
53
    while( keys.hasNext() && !found ) {
54
      final var key = keys.next();
55
      final var patterns = sSettings.getStringSettingList( key );
56
      final var predicate = createFileTypePredicate( patterns );
57
58
      if( predicate.test( path.toFile() ) ) {
59
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
60
        // to a standard name (as defined in the settings.properties file).
61
        final String suffix = key.replace( prefix + '.', "" );
62
        fileType = FileType.from( suffix );
63
        found = true;
64
      }
65
    }
66
67
    return fileType;
68
  }
2869
2970
  /**
3071
   * Responsible for populating the instance variables required by the
3172
   * context.
3273
   */
3374
  public static class Mutator {
3475
    private Path mInputPath;
3576
    private Path mOutputPath;
3677
    private ExportFormat mExportFormat;
37
    private Supplier<Map<String, String>> mDefinitions;
38
    private Supplier<Caret> mCaret;
39
    private Workspace mWorkspace;
78
    private boolean mConcatenate;
79
80
    private Supplier<Path> mThemePath;
81
    private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
82
83
    private Supplier<Map<String, String>> mDefinitions = HashMap::new;
84
    private Supplier<Map<String, String>> mMetadata = HashMap::new;
85
    private Supplier<Caret> mCaret = () -> Caret.builder().build();
86
87
    private Supplier<Path> mImageDir;
88
    private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
89
    private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
90
91
    private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
92
    private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
93
94
    private Supplier<Path> mRWorkingDir;
95
    private Supplier<String> mRScript = () -> "";
96
97
    private Supplier<Boolean> mCurlQuotes = () -> true;
98
    private Supplier<Boolean> mAutoClean = () -> true;
4099
41100
    public void setInputPath( final Path inputPath ) {
101
      assert inputPath != null;
42102
      mInputPath = inputPath;
43
    }
44
45
    public void setInputPath( final File inputPath ) {
46
      setInputPath( inputPath.toPath() );
47103
    }
48104
49105
    public void setOutputPath( final Path outputPath ) {
106
      assert outputPath != null;
50107
      mOutputPath = outputPath;
51108
    }
52109
53110
    public void setOutputPath( final File outputPath ) {
111
      assert outputPath != null;
54112
      setOutputPath( outputPath.toPath() );
113
    }
114
115
    public void setExportFormat( final ExportFormat exportFormat ) {
116
      assert exportFormat != null;
117
      mExportFormat = exportFormat;
118
    }
119
120
    public void setConcatenate( final boolean concatenate ) {
121
      mConcatenate = concatenate;
122
    }
123
124
    public void setLocale( final Supplier<Locale> locale ) {
125
      assert locale != null;
126
      mLocale = locale;
127
    }
128
129
    public void setThemePath( final Supplier<Path> themePath ) {
130
      assert themePath != null;
131
      mThemePath = themePath;
55132
    }
56133
57134
    /**
58135
     * Sets the list of fully interpolated key-value pairs to use when
59136
     * substituting variable names back into the document as variable values.
60137
     * This uses a {@link Callable} reference so that GUI and command-line
61138
     * usage can insert their respective behaviours. That is, this method
62139
     * prevents coupling the GUI to the CLI.
63140
     *
64
     * @param definitions Defines how to retrieve the definitions.
141
     * @param supplier Defines how to retrieve the definitions.
65142
     */
66
    public void setDefinitions(
67
      final Supplier<Map<String, String>> definitions ) {
68
      mDefinitions = definitions;
143
    public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
144
      assert supplier != null;
145
      mDefinitions = supplier;
146
    }
147
148
    public void setMetadata( final Supplier<Map<String, String>> metadata ) {
149
      assert metadata != null;
150
      mMetadata = metadata.get() == null ? HashMap::new : metadata;
69151
    }
70152
71153
    /**
72154
     * Sets the source for deriving the {@link Caret}. Typically, this is
73155
     * the text editor that has focus.
74156
     *
75157
     * @param caret The source for the currently active caret.
76158
     */
77159
    public void setCaret( final Supplier<Caret> caret ) {
160
      assert caret != null;
78161
      mCaret = caret;
79162
    }
80163
81
    public void setExportFormat( final ExportFormat exportFormat ) {
82
      mExportFormat = exportFormat;
164
    public void setImageDir( final Supplier<File> imageDir ) {
165
      assert imageDir != null;
166
      mImageDir = () -> imageDir.get().toPath();
83167
    }
84168
85
    public void setWorkspace( final Workspace workspace ) {
86
      mWorkspace = workspace;
169
    public void setImageOrder( final Supplier<String> imageOrder ) {
170
      assert imageOrder != null;
171
      mImageOrder = imageOrder;
172
    }
173
174
    public void setImageServer( final Supplier<String> imageServer ) {
175
      assert imageServer != null;
176
      mImageServer = imageServer;
177
    }
178
179
    public void setSigilBegan( final Supplier<String> sigilBegan ) {
180
      assert sigilBegan != null;
181
      mSigilBegan = sigilBegan;
182
    }
183
184
    public void setSigilEnded( final Supplier<String> sigilEnded ) {
185
      assert sigilEnded != null;
186
      mSigilEnded = sigilEnded;
187
    }
188
189
    public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
190
      assert rWorkingDir != null;
191
192
      mRWorkingDir = rWorkingDir;
193
    }
194
195
    public void setRScript( final Supplier<String> rScript ) {
196
      assert rScript != null;
197
      mRScript = rScript;
198
    }
199
200
    public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
201
      assert curlQuotes != null;
202
      mCurlQuotes = curlQuotes;
203
    }
204
205
    public void setAutoClean( final Supplier<Boolean> autoClean ) {
206
      assert autoClean != null;
207
      mAutoClean = autoClean;
87208
    }
88209
  }
89210
90211
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
91212
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
92
  }
93
94
  /**
95
   * @param inputPath Path to the document to process.
96
   * @param format    Indicate configuration options for export format.
97
   * @return A context that may be used for processing documents.
98
   */
99
  public static ProcessorContext create(
100
    final Path inputPath,
101
    final ExportFormat format ) {
102
    return builder()
103
      .with( Mutator::setInputPath, inputPath )
104
      .with( Mutator::setExportFormat, format )
105
      .build();
106213
  }
107214
...
116223
117224
    mMutator = mutator;
225
  }
226
227
  public Path getInputPath() {
228
    return mMutator.mInputPath;
229
  }
230
231
  /**
232
   * Fully qualified file name to use when exporting (e.g., document.pdf).
233
   *
234
   * @return Full path to a file name.
235
   */
236
  public Path getOutputPath() {
237
    return mMutator.mOutputPath;
238
  }
239
240
  public ExportFormat getExportFormat() {
241
    return mMutator.mExportFormat;
242
  }
243
244
  public Locale getLocale() {
245
    return mMutator.mLocale.get();
118246
  }
119247
...
133261
   */
134262
  public InterpolatingMap getInterpolatedDefinitions() {
135
    final var map = new InterpolatingMap(
136
      createDefinitionSigilOperator(), getDefinitions()
137
    );
138
139
    map.interpolate();
140
141
    return map;
142
  }
143
144
  /**
145
   * Fully qualified file name to use when exporting (e.g., document.pdf).
146
   *
147
   * @return Full path to a file name.
148
   */
149
  public Path getOutputPath() {
150
    return mMutator.mOutputPath;
263
    return new InterpolatingMap(
264
      createDefinitionKeyOperator(), getDefinitions()
265
    ).interpolate();
151266
  }
152267
153
  public ExportFormat getExportFormat() {
154
    return mMutator.mExportFormat;
268
  public Map<String, String> getMetadata() {
269
    return mMutator.mMetadata.get();
155270
  }
156271
...
182297
  }
183298
184
  public Path getInputPath() {
185
    return mMutator.mInputPath;
299
  FileType getInputFileType() {
300
    return lookup( getInputPath() );
186301
  }
187302
188
  FileType getFileType() {
189
    return lookup( getInputPath() );
303
  public Path getImageDir() {
304
    return mMutator.mImageDir.get();
190305
  }
191306
192
  public Workspace getWorkspace() {
193
    return mMutator.mWorkspace;
307
  public Iterable<String> getImageOrder() {
308
    assert mMutator.mImageOrder != null;
309
310
    final var order = mMutator.mImageOrder.get();
311
    final var token = order.contains( "," ) ? ',' : ' ';
312
313
    return Splitter.on( token ).split( token + order );
194314
  }
195315
196
  public SigilKeyOperator createSigilOperator() {
197
    return getWorkspace().createSigilOperator( getInputPath() );
316
  public String getImageServer() {
317
    return mMutator.mImageServer.get();
198318
  }
199319
200
  public SigilKeyOperator createDefinitionSigilOperator() {
201
    return getWorkspace().createDefinitionKeyOperator();
320
  public Path getThemePath() {
321
    return mMutator.mThemePath.get();
322
  }
323
324
  public Path getRWorkingDir() {
325
    return mMutator.mRWorkingDir.get();
326
  }
327
328
  public String getRScript() {
329
    return mMutator.mRScript.get();
330
  }
331
332
  public boolean getCurlQuotes() {
333
    return mMutator.mCurlQuotes.get();
334
  }
335
336
  public boolean getAutoClean() {
337
    return mMutator.mAutoClean.get();
338
  }
339
340
  /**
341
   * Answers whether to process a single text file or all text files in
342
   * the same directory as a single text file. See {@link #getInputPath()}
343
   * for the file to process (or all files in its directory).
344
   *
345
   * @return {@code true} means to process all text files, {@code false}
346
   * means to process a single file.
347
   */
348
  public boolean getConcatenate() {
349
    return mMutator.mConcatenate;
350
  }
351
352
  public SigilKeyOperator createKeyOperator() {
353
    return createKeyOperator( getInputPath() );
354
  }
355
356
  /**
357
   * Returns the sigil operator for the given {@link Path}.
358
   *
359
   * @param path The type of file being edited, from its extension.
360
   */
361
  private SigilKeyOperator createKeyOperator( final Path path ) {
362
    assert path != null;
363
364
    return valueFrom( path ) == TEXT_PROPERTIES
365
      ? createPropertyKeyOperator()
366
      : createDefinitionKeyOperator();
367
  }
368
369
  private SigilKeyOperator createPropertyKeyOperator() {
370
    return new PropertyKeyOperator();
371
  }
372
373
  private SigilKeyOperator createDefinitionKeyOperator() {
374
    final var began = mMutator.mSigilBegan.get();
375
    final var ended = mMutator.mSigilEnded.get();
376
377
    return new SigilKeyOperator( began, ended );
202378
  }
203379
}
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
22
package com.keenwrite.processors;
33
4
import com.keenwrite.AbstractFileFactory;
54
import com.keenwrite.processors.markdown.MarkdownProcessor;
5
import com.keenwrite.processors.r.InlineRProcessor;
6
import com.keenwrite.processors.r.RVariableProcessor;
67
8
import static com.keenwrite.ExportFormat.MARKDOWN_PLAIN;
9
import static com.keenwrite.io.FileType.RMARKDOWN;
10
import static com.keenwrite.io.FileType.SOURCE;
711
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
812
913
/**
1014
 * Responsible for creating processors capable of parsing, transforming,
1115
 * interpolating, and rendering known file types.
1216
 */
13
public final class ProcessorFactory extends AbstractFileFactory {
17
public final class ProcessorFactory {
1418
1519
  private ProcessorFactory() {
...
3034
  public static Processor<String> createProcessors(
3135
    final ProcessorContext context, final Processor<String> preview ) {
32
    return ProcessorFactory.createProcessor( context, preview );
36
    return createProcessor( context, preview );
3337
  }
3438
...
5256
    // to SVG. Without conversion would require client-side rendering of
5357
    // math (such as using the JavaScript-based KaTeX engine).
54
    final var successor = switch( context.getExportFormat() ) {
58
    final var outputType = context.getExportFormat();
59
60
    final var successor = switch( outputType ) {
5561
      case NONE -> preview;
5662
      case XHTML_TEX -> createXhtmlProcessor( context );
5763
      case APPLICATION_PDF -> createPdfProcessor( context );
5864
      default -> createIdentityProcessor( context );
5965
    };
6066
61
    final var processor = switch( context.getFileType() ) {
62
      case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor, context );
63
      default -> createPreformattedProcessor( successor );
64
    };
67
    final var inputType = context.getInputFileType();
68
    final Processor<String> processor;
69
70
    if( preview == null && outputType == MARKDOWN_PLAIN ) {
71
      processor = inputType == RMARKDOWN
72
        ? createInlineRProcessor( successor, context )
73
        : createMarkdownProcessor( successor, context );
74
    }
75
    else {
76
      processor = inputType == SOURCE || inputType == RMARKDOWN
77
        ? createMarkdownProcessor( successor, context )
78
        : createPreformattedProcessor( successor );
79
    }
6580
6681
    return new ExecutorProcessor<>( processor );
...
7893
    return IDENTITY;
7994
  }
95
8096
  /**
8197
   * Instantiates a {@link Processor} responsible for parsing Markdown and
...
88104
    final Processor<String> successor,
89105
    final ProcessorContext context ) {
90
    final var dp = createDefinitionProcessor( successor, context );
106
    final var dp = createVariableProcessor( successor, context );
91107
    return MarkdownProcessor.create( dp, context );
92108
  }
93109
94
  private static Processor<String> createDefinitionProcessor(
110
  private static Processor<String> createVariableProcessor(
95111
    final Processor<String> successor,
96112
    final ProcessorContext context ) {
97113
    return new VariableProcessor( successor, context );
114
  }
115
116
  /**
117
   * Instantiates a processor capable of executing R statements (along with
118
   * R variable references) and embedding the result into the document. This
119
   * is useful for converting R Markdown documents into plain Markdown.
120
   *
121
   * @param successor {@link Processor} invoked after {@link InlineRProcessor}.
122
   * @param context   {@link Processor} configuration settings.
123
   * @return An instance of {@link Processor} that performs variable
124
   * interpolation, replacement, and execution of R statements.
125
   */
126
  private static Processor<String> createInlineRProcessor(
127
    final Processor<String> successor, final ProcessorContext context ) {
128
    final var irp = new InlineRProcessor( successor, context );
129
    final var rvp = new RVariableProcessor( irp, context );
130
    return createVariableProcessor( rvp, context );
98131
  }
99132
M src/main/java/com/keenwrite/processors/VariableProcessor.java
3131
    super( successor );
3232
33
    mSigilOperator = createKeyOperator( context );
3433
    mContext = context;
34
    mSigilOperator = createKeyOperator( context );
3535
  }
3636
...
4444
  protected UnaryOperator<String> createKeyOperator(
4545
    final ProcessorContext context ) {
46
    return context.createSigilOperator();
46
    return context.createKeyOperator();
4747
  }
4848
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
33
44
import com.keenwrite.dom.DocumentParser;
5
import com.keenwrite.preferences.Workspace;
65
import com.keenwrite.ui.heuristics.WordCounter;
76
import com.whitemagicsoftware.keenquotes.Contractions;
87
import com.whitemagicsoftware.keenquotes.Converter;
9
import javafx.beans.property.ListProperty;
108
import org.w3c.dom.Document;
119
1210
import java.io.FileNotFoundException;
1311
import java.nio.file.Path;
1412
import java.util.LinkedHashMap;
1513
import java.util.List;
1614
import java.util.Locale;
1715
import java.util.Map;
18
import java.util.Map.Entry;
19
import java.util.regex.Pattern;
2016
2117
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
2218
import static com.keenwrite.dom.DocumentParser.createMeta;
2319
import static com.keenwrite.dom.DocumentParser.visit;
2420
import static com.keenwrite.events.StatusEvent.clue;
2521
import static com.keenwrite.io.HttpFacade.httpGet;
26
import static com.keenwrite.preferences.AppKeys.*;
2722
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2823
import static com.whitemagicsoftware.keenquotes.Converter.CHARS;
2924
import static com.whitemagicsoftware.keenquotes.ParserFactory.ParserType.PARSER_XML;
3025
import static java.lang.String.format;
3126
import static java.lang.String.valueOf;
3227
import static java.nio.file.Files.copy;
3328
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
34
import static java.util.regex.Pattern.UNICODE_CHARACTER_CLASS;
35
import static java.util.regex.Pattern.compile;
3629
3730
/**
3831
 * Responsible for making an XHTML document complete by wrapping it with html
3932
 * and body elements. This doesn't have to be super-efficient because it's
4033
 * not run in real-time.
4134
 */
4235
public final class XhtmlProcessor extends ExecutorProcessor<String> {
43
  private final static Pattern BLANK =
44
    compile( "\\p{Blank}", UNICODE_CHARACTER_CLASS );
45
4636
  private final static Converter sTypographer = new Converter(
4737
    lex -> clue( lex.toString() ), contractions(), CHARS, PARSER_XML );
...
10595
10696
      final var document = DocumentParser.toString( doc );
97
      final var curl = mContext.getCurlQuotes();
10798
108
      return curl() ? sTypographer.apply( document ) : document;
99
      return curl ? sTypographer.apply( document ) : document;
109100
    } catch( final Exception ex ) {
110101
      clue( ex );
...
139130
   */
140131
  private Map<String, String> createMetaDataMap( final Document doc ) {
141
    final Map<String, String> result = new LinkedHashMap<>();
142
    final var metadata = getMetaData();
132
    final var result = new LinkedHashMap<String, String>();
133
    final var metadata = getMetadata();
143134
    final var map = mContext.getInterpolatedDefinitions();
144135
145
    metadata.forEach( entry -> result.put(
146
      entry.getKey(), map.interpolate( entry.getValue() ) )
136
    metadata.forEach(
137
      ( key, value ) -> result.put( key, map.interpolate( value ) )
147138
    );
148139
    result.put( "count", wordCount( doc ) );
...
158149
   * @return The document metadata.
159150
   */
160
  private ListProperty<Entry<String, String>> getMetaData() {
161
    return getWorkspace().listsProperty( KEY_DOC_META );
151
  private Map<String, String> getMetadata() {
152
    return mContext.getMetadata();
162153
  }
163154
...
193184
    }
194185
    else {
195
      final var extensions = " " + getImageOrder().trim();
186
      final var extensions = getImageOrder();
196187
      var imagePath = getImagePath();
197188
      var found = false;
198189
199
      // By including " " in the extensions, the first element returned
200
      // will be the empty string. Thus the first extension to try is the
201
      // file's default extension. Subsequent iterations will try to find
202
      // a file that has a name matching one of the preferred extensions.
203
      for( final var extension : BLANK.split( extensions ) ) {
190
      for( final var extension : extensions ) {
204191
        final var filename = format(
205192
          "%s%s%s", src, extension.isBlank() ? "" : ".", extension );
...
226213
227214
  private String getImagePath() {
228
    return getWorkspace().getFile( KEY_IMAGES_DIR ).toString();
215
    return mContext.getImageDir().toString();
229216
  }
230217
231
  private String getImageOrder() {
232
    return getWorkspace().getString( KEY_IMAGES_ORDER );
218
  /**
219
   * By including an "empty" extension, the first element returned
220
   * will be the empty string. Thus, the first extension to try is the
221
   * file's default extension. Subsequent iterations will try to find
222
   * a file that has a name matching one of the preferred extensions.
223
   *
224
   * @return A list of extensions, including an empty string at the start.
225
   */
226
  private Iterable<String> getImageOrder() {
227
    return mContext.getImageOrder();
233228
  }
234229
...
243238
  }
244239
245
  private Workspace getWorkspace() {
246
    return mContext.getWorkspace();
240
  private Locale getLocale() {
241
    return mContext.getLocale();
247242
  }
248
249
  private Locale locale() {return getWorkspace().getLocale();}
250243
251244
  private String wordCount( final Document doc ) {
252245
    final var sb = new StringBuilder( 65536 * 10 );
253246
254247
    visit(
255248
      doc,
256249
      "//*[normalize-space( text() ) != '']",
257250
      node -> sb.append( node.getTextContent() )
258251
    );
259
260
    return valueOf( WordCounter.create( locale() ).count( sb.toString() ) );
261
  }
262252
263
  /**
264
   * Answers whether straight quotation marks should be curled.
265
   *
266
   * @return {@code false} to prevent curling straight quotes.
267
   */
268
  private boolean curl() {
269
    return getWorkspace().getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES );
253
    return valueOf( WordCounter.create( getLocale() ).count( sb.toString() ) );
270254
  }
271255
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
5353
  @Override
5454
  List<Extension> createExtensions( final ProcessorContext context ) {
55
    final var editorFile = context.getInputPath();
56
    final var mediaType = MediaType.valueFrom( editorFile );
55
    final var inputPath = context.getInputPath();
56
    final var mediaType = MediaType.valueFrom( inputPath );
5757
    final Processor<String> processor;
5858
    final List<Extension> extensions = new ArrayList<>();
M src/main/java/com/keenwrite/processors/markdown/extensions/CaretExtension.java
22
package com.keenwrite.processors.markdown.extensions;
33
4
import com.keenwrite.Caret;
4
import com.keenwrite.editors.common.Caret;
55
import com.keenwrite.constants.Constants;
66
import com.keenwrite.processors.ProcessorContext;
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
33
44
import com.keenwrite.ExportFormat;
5
import com.keenwrite.preferences.Workspace;
65
import com.keenwrite.processors.ProcessorContext;
76
import com.vladsch.flexmark.ast.Image;
...
1817
import static com.keenwrite.ExportFormat.NONE;
1918
import static com.keenwrite.events.StatusEvent.clue;
20
import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_DIR;
21
import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_ORDER;
2219
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2320
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2421
import static com.vladsch.flexmark.html.renderer.LinkStatus.VALID;
25
import static org.renjin.repackaged.guava.base.Splitter.on;
2622
2723
/**
2824
 * Responsible for ensuring that images can be rendered relative to a path.
2925
 * This allows images to be located virtually anywhere.
3026
 */
3127
public class ImageLinkExtension extends HtmlRendererAdapter {
3228
33
  private final Path mBaseDir;
34
  private final Workspace mWorkspace;
35
  private final ExportFormat mExportFormat;
29
  private final ProcessorContext mContext;
3630
37
  private ImageLinkExtension(
38
    @NotNull final ProcessorContext context ) {
39
    mBaseDir = context.getBaseDir();
40
    mWorkspace = context.getWorkspace();
41
    mExportFormat = context.getExportFormat();
31
  private ImageLinkExtension( @NotNull final ProcessorContext context ) {
32
    mContext = context;
4233
  }
4334
...
111102
      }
112103
113
      if( mExportFormat != NONE ) {
104
      if( mContext.getExportFormat() != NONE ) {
114105
        return valid( link, uri );
115106
      }
116107
117108
      try {
118109
        // Compute the path to the image file. The base directory should
119110
        // be an absolute path to the file being edited, without an extension.
120
        final var imagesDir = getUserImagesDir();
111
        final var imagesDir = getImageDir();
121112
        final var relativeDir = imagesDir.toString().isEmpty()
122113
          ? imagesDir : baseDir.relativize( imagesDir );
123114
        final var imageFile = Path.of(
124115
          baseDir.toString(), relativeDir.toString(), uri );
125116
126
        for( final var ext : getImageExtensions() ) {
117
        for( final var ext : getImageOrder() ) {
127118
          var file = new File( imageFile.toString() + '.' + ext );
128119
...
147138
    }
148139
149
    private Path getUserImagesDir() {
150
      return mWorkspace.getFile( KEY_IMAGES_DIR ).toPath();
140
    private Path getImageDir() {
141
      return mContext.getImageDir();
151142
    }
152143
153
    private Iterable<String> getImageExtensions() {
154
      return on( ' ' ).split( mWorkspace.getString( KEY_IMAGES_ORDER ) );
144
    private Iterable<String> getImageOrder() {
145
      return mContext.getImageOrder();
155146
    }
156147
157148
    private Path getBaseDir() {
158
      return mBaseDir;
149
      return mContext.getBaseDir();
159150
    }
160151
  }
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
22
package com.keenwrite.processors.markdown.extensions.fences;
33
4
import com.keenwrite.preferences.Workspace;
54
import com.keenwrite.preview.DiagramUrlGenerator;
6
import com.keenwrite.processors.VariableProcessor;
75
import com.keenwrite.processors.Processor;
86
import com.keenwrite.processors.ProcessorContext;
7
import com.keenwrite.processors.VariableProcessor;
98
import com.keenwrite.processors.markdown.MarkdownProcessor;
109
import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter;
...
2322
import java.util.Set;
2423
25
import static com.keenwrite.preferences.AppKeys.KEY_IMAGES_SERVER;
2624
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2725
import static com.vladsch.flexmark.html.renderer.CoreNodeRenderer.CODE_CONTENT;
...
3735
3836
  private final Processor<String> mProcessor;
39
  private final Workspace mWorkspace;
37
  private final ProcessorContext mContext;
4038
4139
  public FencedBlockExtension(
42
    final Processor<String> processor, final Workspace workspace ) {
40
    final Processor<String> processor, final ProcessorContext context ) {
4341
    assert processor != null;
44
    assert workspace != null;
42
    assert context != null;
4543
    mProcessor = processor;
46
    mWorkspace = workspace;
44
    mContext = context;
4745
  }
4846
...
6967
    assert processor != null;
7068
    assert context != null;
71
    return new FencedBlockExtension( processor, context.getWorkspace() );
69
    return new FencedBlockExtension( processor, context );
7270
  }
7371
...
107105
          final var content = node.getContentChars().normalizeEOL();
108106
          final var text = mProcessor.apply( content );
109
          final var server = mWorkspace.getString( KEY_IMAGES_SERVER );
107
          final var server = mContext.getImageServer();
110108
          final var source = DiagramUrlGenerator.toUrl( server, type, text );
111109
          final var link = context.resolveLink( LINK, source, false );
M src/main/java/com/keenwrite/processors/r/InlineRProcessor.java
22
package com.keenwrite.processors.r;
33
4
import com.keenwrite.preferences.Workspace;
54
import com.keenwrite.processors.Processor;
65
import com.keenwrite.processors.ProcessorContext;
76
import com.keenwrite.processors.VariableProcessor;
87
import com.keenwrite.sigils.RKeyOperator;
9
import javafx.beans.property.Property;
108
import org.jetbrains.annotations.NotNull;
119
12
import java.io.File;
13
import java.nio.file.Path;
1410
import java.util.HashMap;
1511
import java.util.concurrent.atomic.AtomicBoolean;
1612
1713
import static com.keenwrite.constants.Constants.STATUS_PARSE_ERROR;
1814
import static com.keenwrite.events.StatusEvent.clue;
19
import static com.keenwrite.preferences.AppKeys.KEY_R_DIR;
20
import static com.keenwrite.preferences.AppKeys.KEY_R_SCRIPT;
2115
import static com.keenwrite.processors.r.RVariableProcessor.escape;
2216
import static com.keenwrite.processors.text.TextReplacementFactory.replace;
...
6054
   */
6155
  public void init() {
62
    final var bootstrap = getBootstrapScript();
56
    final var context = getContext();
57
    final var bootstrap = context.getRScript();
6358
6459
    if( !bootstrap.isBlank() ) {
65
      final var wd = getWorkingDirectory();
60
      final var wd = context.getRWorkingDir();
6661
      final var dir = wd.toString().replace( '\\', '/' );
6762
      final var definitions = getContext().getDefinitions();
...
7974
        Engine.eval( replace( bootstrap, map ) );
8075
        mReady.set( true );
81
      } catch( final Exception ignored ) {
76
      } catch( final Exception ex ) {
77
        clue( ex );
8278
        // A problem with the bootstrap script is likely caused by variables
8379
        // not being loaded. This implies that the R processor is being invoked
...
154150
    // Copy from the previous index to the end of the string.
155151
    return sb.append( text.substring( min( prevIndex, length ) ) ).toString();
156
  }
157
158
  /**
159
   * Return the given path if not {@code null}, otherwise return the path to
160
   * the user's directory.
161
   *
162
   * @return A non-null path.
163
   */
164
  private Path getWorkingDirectory() {
165
    return workingDirectoryProperty().getValue().toPath();
166
  }
167
168
  private Property<File> workingDirectoryProperty() {
169
    return getWorkspace().fileProperty( KEY_R_DIR );
170
  }
171
172
  /**
173
   * Loads the R init script from the application's persisted preferences.
174
   *
175
   * @return A non-null string, possibly empty.
176
   */
177
  private String getBootstrapScript() {
178
    return bootstrapScriptProperty().getValue();
179
  }
180
181
  private Property<String> bootstrapScriptProperty() {
182
    return getWorkspace().valuesProperty( KEY_R_SCRIPT );
183
  }
184
185
  private Workspace getWorkspace() {
186
    return getContext().getWorkspace();
187152
  }
188153
}
A src/main/java/com/keenwrite/security/PermissiveCertificate.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.security;
3
4
import javax.net.ssl.*;
5
import java.security.SecureRandom;
6
import java.security.cert.X509Certificate;
7
8
import static javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier;
9
import static javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory;
10
11
/**
12
 * Responsible for trusting all certificate chains. The purpose of this class
13
 * is to work-around certificate issues caused by software that blocks
14
 * HTTP requests. For example, zscaler may block HTTP requests to kroki.io
15
 * when generating diagrams.
16
 */
17
public final class PermissiveCertificate {
18
  /**
19
   * Create a trust manager that does not validate certificate chains.
20
   */
21
  private final static TrustManager[] TRUST_ALL_CERTS = new TrustManager[]{
22
    new X509TrustManager() {
23
      @Override
24
      public X509Certificate[] getAcceptedIssuers() {
25
        return new X509Certificate[ 0 ];
26
      }
27
28
      @Override
29
      public void checkClientTrusted(
30
        X509Certificate[] certs, String authType ) {
31
      }
32
33
      @Override
34
      public void checkServerTrusted(
35
        X509Certificate[] certs, String authType ) {
36
      }
37
    }
38
  };
39
40
  /**
41
   * Responsible for permitting all hostnames for making HTTP requests.
42
   */
43
  private static class PermissiveHostNameVerifier implements HostnameVerifier {
44
    @Override
45
    public boolean verify( final String hostname, final SSLSession session ) {
46
      return true;
47
    }
48
  }
49
50
  /**
51
   * Install the all-trusting trust manager. If this fails it means that in
52
   * certain situations the HTML preview may fail to render diagrams. A way
53
   * to work around the issue is to install a local server for generating
54
   * diagrams.
55
   */
56
  public static boolean installTrustManager() {
57
    try {
58
      final var context = SSLContext.getInstance( "SSL" );
59
      context.init( null, TRUST_ALL_CERTS, new SecureRandom() );
60
      setDefaultSSLSocketFactory( context.getSocketFactory() );
61
      setDefaultHostnameVerifier( new PermissiveHostNameVerifier() );
62
      return true;
63
    } catch( final Exception ex ) {
64
      return false;
65
    }
66
  }
67
68
  /**
69
   * Use {@link #installTrustManager()}.
70
   */
71
  private PermissiveCertificate() {
72
  }
73
}
174
M src/main/java/com/keenwrite/typesetting/Typesetter.java
22
package com.keenwrite.typesetting;
33
4
import com.keenwrite.io.SysFile;
5
import com.keenwrite.collections.BoundedCache;
6
import com.keenwrite.util.GenericBuilder;
7
8
import java.io.*;
9
import java.nio.file.NoSuchFileException;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Scanner;
15
import java.util.concurrent.Callable;
16
import java.util.regex.Pattern;
17
18
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static java.lang.ProcessBuilder.Redirect.DISCARD;
21
import static java.lang.String.format;
22
import static java.lang.System.currentTimeMillis;
23
import static java.lang.System.getProperty;
24
import static java.nio.file.Files.*;
25
import static java.util.Arrays.asList;
26
import static java.util.concurrent.TimeUnit.*;
27
import static org.apache.commons.io.FilenameUtils.removeExtension;
28
29
/**
30
 * Responsible for invoking an executable to typeset text. This will
31
 * construct suitable command-line arguments to invoke the typesetting engine.
32
 */
33
public class Typesetter {
34
  private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
35
36
  private final Mutator mMutator;
37
38
  public static GenericBuilder<Mutator, Typesetter> builder() {
39
    return GenericBuilder.of( Mutator::new, Typesetter::new );
40
  }
41
42
  public static final class Mutator {
43
    private Path mInputPath;
44
    private Path mOutputPath;
45
    private Path mThemePath;
46
    private String mThemeName;
47
    private boolean mAutoclean;
48
49
    /**
50
     * @param inputPath The input document to typeset.
51
     */
52
    public void setInputPath( final Path inputPath ) {
53
      mInputPath = inputPath;
54
    }
55
56
    /**
57
     * @param outputPath Path to the finished typeset document to create.
58
     */
59
    public void setOutputPath( final Path outputPath ) {
60
      mOutputPath = outputPath;
61
    }
62
63
    /**
64
     * @param themePath Fully qualified path to the theme directory.
65
     */
66
    public void setThemePath( final Path themePath ) {
67
      mThemePath = themePath;
68
    }
69
70
    /**
71
     * @param themePath Fully qualified path to the theme directory.
72
     */
73
    public void setThemePath( final File themePath ) {
74
      setThemePath( themePath.toPath() );
75
    }
76
77
    /**
78
     * @param themeName Name of theme to apply when generating the PDF file.
79
     */
80
    public void setThemeName( final String themeName ) {
81
      mThemeName = themeName;
82
    }
83
84
    /**
85
     * @param autoclean {@code true} to remove all temporary files after
86
     *                  typesetter produces a PDF file.
87
     */
88
    public void setAutoclean( final boolean autoclean ) {
89
      mAutoclean = autoclean;
90
    }
91
  }
92
93
  public static boolean canRun() {
94
    return TYPESETTER.canRun();
95
  }
96
97
  /**
98
   * Calculates the time that has elapsed from the current time to the
99
   * given moment in time.
100
   *
101
   * @param start The starting time, which should be before the current time.
102
   * @return A human-readable formatted time.
103
   * @see #asElapsed(long)
104
   */
105
  private static String since( final long start ) {
106
    return asElapsed( currentTimeMillis() - start );
107
  }
108
109
  /**
110
   * Converts an elapsed time to a human-readable format (hours, minutes,
111
   * seconds, and milliseconds).
112
   *
113
   * @param elapsed An elapsed time, in milliseconds.
114
   * @return Human-readable elapsed time.
115
   */
116
  private static String asElapsed( final long elapsed ) {
117
    final var hours = MILLISECONDS.toHours( elapsed );
118
    final var eHours = elapsed - HOURS.toMillis( hours );
119
    final var minutes = MILLISECONDS.toMinutes( eHours );
120
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
121
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
122
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
123
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
124
125
    return format( "%02d:%02d:%02d.%03d",
126
                   hours, minutes, seconds, milliseconds );
127
  }
128
129
  /**
130
   * Launches a task to typeset a document.
131
   */
132
  private class TypesetTask implements Callable<Boolean> {
133
    private final List<String> mArgs = new ArrayList<>();
134
135
    /**
136
     * Working directory must be set because ConTeXt cannot write the
137
     * result to an arbitrary location.
138
     */
139
    private final Path mDirectory;
140
141
    private TypesetTask() {
142
      final var parentDir = getOutputPath().getParent();
143
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
144
    }
145
146
    /**
147
     * Initializes ConTeXt, which means creating the cache directory if it
148
     * doesn't already exist. The theme entry point must be named 'main.tex'.
149
     *
150
     * @return {@code true} if the cache directory exists.
151
     */
152
    private boolean reinitialize() {
153
      final var filename = getOutputPath().getFileName();
154
      final var themes = getThemePath();
155
      final var theme = getThemeName();
156
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
157
158
      // Ensure invoking multiple times will load the correct arguments.
159
      mArgs.clear();
160
      mArgs.add( TYPESETTER.getName() );
161
162
      if( cacheExists ) {
163
        mArgs.add( "--autogenerate" );
164
        mArgs.add( "--script" );
165
        mArgs.add( "mtx-context" );
166
        mArgs.add( "--batchmode" );
167
        mArgs.add( "--nonstopmode" );
168
        mArgs.add( "--purgeall" );
169
        mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" );
170
        mArgs.add( "--environment='main'" );
171
        mArgs.add( "--result='" + filename + "'" );
172
        mArgs.add( getInputPath().toString() );
173
174
        final var sb = new StringBuilder( 128 );
175
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
176
        clue( sb.toString() );
177
      }
178
      else {
179
        mArgs.add( "--generate" );
180
      }
181
182
      return cacheExists;
183
    }
184
185
    /**
186
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first
187
     * try. If the cache directory doesn't exist, attempt to create it, then
188
     * call ConTeXt to generate the PDF. This is brittle because if the
189
     * directory is empty, or not populated with cached data, a false positive
190
     * will be returned, resulting in no PDF being created.
191
     *
192
     * @return {@code true} if the document was typeset successfully.
193
     * @throws IOException          If the process could not be started.
194
     * @throws InterruptedException If the process was killed.
195
     */
196
    private boolean typeset() throws IOException, InterruptedException {
197
      return reinitialize() ? call() : call() && reinitialize() && call();
198
    }
199
200
    @Override
201
    public Boolean call() throws IOException, InterruptedException {
202
      final var stdout = new BoundedCache<String, String>( 150 );
203
      final var builder = new ProcessBuilder( mArgs );
204
      builder.directory( mDirectory.toFile() );
205
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
206
207
      // Without redirecting (or draining) stderr, the command may not
208
      // terminate successfully.
209
      builder.redirectError( DISCARD );
210
211
      final var process = builder.start();
212
      final var stream = process.getInputStream();
213
214
      // Reading from stdout allows slurping page numbers while generating.
215
      final var listener = new PaginationListener( stream, stdout );
216
      listener.start();
217
218
      // Even though the process has completed, there may be incomplete I/O.
219
      process.waitFor();
220
221
      // Allow time for any incomplete I/O to take place.
222
      process.waitFor( 1, SECONDS );
223
224
      final var exit = process.exitValue();
225
      process.destroy();
226
227
      // If there was an error, the typesetter will leave behind log, pdf, and
228
      // error files.
229
      if( exit > 0 ) {
230
        final var xmlName = getInputPath().getFileName().toString();
231
        final var srcName = getOutputPath().getFileName().toString();
232
        final var logName = newExtension( xmlName, ".log" );
233
        final var errName = newExtension( xmlName, "-error.log" );
234
        final var pdfName = newExtension( xmlName, ".pdf" );
235
        final var tuaName = newExtension( xmlName, ".tua" );
236
        final var badName = newExtension( srcName, ".log" );
237
238
        log( badName );
239
        log( logName );
240
        log( errName );
241
        log( stdout.keySet().stream().toList() );
242
243
        // Users may opt to keep these files around for debugging purposes.
244
        if( autoclean() ) {
245
          deleteIfExists( logName );
246
          deleteIfExists( errName );
247
          deleteIfExists( pdfName );
248
          deleteIfExists( badName );
249
          deleteIfExists( tuaName );
250
        }
251
      }
252
253
      // Exit value for a successful invocation of the typesetter. This value
254
      // value is returned when creating the cache on the first run as well as
255
      // creating PDFs on subsequent runs (after the cache has been created).
256
      // Users don't care about exit codes, only whether the PDF was generated.
257
      return exit == 0;
258
    }
259
260
    private Path newExtension( final String baseName, final String ext ) {
261
      return getOutputPath().resolveSibling( removeExtension( baseName ) + ext );
262
    }
263
264
    /**
265
     * Fires a status message for each line in the given file. The file format
266
     * is somewhat machine-readable, but no effort beyond line splitting is
267
     * made to parse the text.
268
     *
269
     * @param path Path to the file containing error messages.
270
     */
271
    private void log( final Path path ) throws IOException {
272
      if( exists( path ) ) {
273
        log( readAllLines( path ) );
274
      }
275
    }
276
277
    private void log( final List<String> lines ) {
278
      final var splits = new ArrayList<String>( lines.size() * 2 );
279
280
      for( final var line : lines ) {
281
        splits.addAll( asList( line.split( "\\\\n" ) ) );
282
      }
283
284
      clue( splits );
285
    }
286
287
    /**
288
     * Returns the location of the cache directory.
289
     *
290
     * @return A fully qualified path to the location to store temporary
291
     * files between typesetting runs.
292
     */
293
    private java.io.File getCacheDir() {
294
      final var temp = getProperty( "java.io.tmpdir" );
295
      final var cache = Path.of( temp, "luatex-cache" );
296
      return cache.toFile();
297
    }
298
299
    /**
300
     * Answers whether the given directory is empty. The typesetting software
301
     * creates a non-empty directory by default. The return value from this
302
     * method is a proxy to answering whether the typesetter has been run for
303
     * the first time or not.
304
     *
305
     * @param path The directory to check for emptiness.
306
     * @return {@code true} if the directory is empty.
307
     */
308
    private boolean isEmpty( final Path path ) {
309
      try( final var stream = newDirectoryStream( path ) ) {
310
        return !stream.iterator().hasNext();
311
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
312
        // A missing directory means it doesn't exist, ergo is empty.
313
        return true;
314
      } catch( final IOException ex ) {
315
        throw new RuntimeException( ex );
316
      }
317
    }
318
  }
319
320
  /**
321
   * Responsible for parsing the output from the typesetting engine and
322
   * updating the status bar to provide assurance that typesetting is
323
   * executing.
324
   *
325
   * <p>
326
   * Example lines written to standard output:
327
   * </p>
328
   * <pre>{@code
329
   * pages           > flushing realpage 15, userpage 15, subpage 15
330
   * pages           > flushing realpage 16, userpage 16, subpage 16
331
   * pages           > flushing realpage 1, userpage 1, subpage 1
332
   * pages           > flushing realpage 2, userpage 2, subpage 2
333
   * }</pre>
334
   * <p>
335
   * The lines are parsed; the first number is displayed in a status bar
336
   * message.
337
   * </p>
338
   */
339
  private static class PaginationListener extends Thread {
340
    private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
341
342
    private final InputStream mInputStream;
343
344
    private final Map<String, String> mCache;
345
346
    public PaginationListener(
347
      final InputStream in, final Map<String, String> cache ) {
348
      mInputStream = in;
349
      mCache = cache;
350
    }
351
352
    @Override
353
    public void run() {
354
      try( final var reader = createReader( mInputStream ) ) {
355
        int pageCount = 1;
356
        int passCount = 1;
357
        int pageTotal = 0;
358
        String line;
359
360
        while( (line = reader.readLine()) != null ) {
361
          mCache.put( line, "" );
362
363
          if( line.startsWith( "pages" ) ) {
364
            // The bottleneck will be the typesetting engine writing to stdout,
365
            // not the parsing of stdout.
366
            final var scanner = new Scanner( line ).useDelimiter( DIGITS );
367
            final var digits = scanner.next();
368
            final var page = Integer.parseInt( digits );
369
370
            // If the page number is less than the previous page count, it
371
            // means that the typesetting engine has started another pass.
372
            if( page < pageCount ) {
373
              passCount++;
374
              pageTotal = pageCount;
375
            }
376
377
            pageCount = page;
378
379
            // Inform the user of pages being typeset.
380
            clue( "Main.status.typeset.page",
381
                  pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
382
            );
383
          }
384
        }
385
      } catch( final IOException ex ) {
386
        clue( ex );
387
        throw new RuntimeException( ex );
388
      }
389
    }
390
391
    private BufferedReader createReader( final InputStream inputStream ) {
392
      return new BufferedReader( new InputStreamReader( inputStream ) );
393
    }
394
  }
395
396
  /**
397
   * Creates a new {@link Typesetter} instance capable of configuring the
398
   * typesetter used to generate a typeset document.
399
   */
400
  private Typesetter( final Mutator mutator ) {
401
    assert mutator != null;
402
403
    mMutator = mutator;
404
  }
405
406
  /**
407
   * This will typeset the document using a new process. The return value only
408
   * indicates whether the typesetter exists, not whether the typesetting was
409
   * successful.
410
   *
411
   * @throws IOException                 If the process could not be started.
412
   * @throws InterruptedException        If the process was killed.
413
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
414
   */
415
  public void typeset()
416
    throws IOException, InterruptedException, TypesetterNotFoundException {
417
    if( TYPESETTER.canRun() ) {
418
      final var outputPath = getOutputPath();
419
420
      clue( "Main.status.typeset.began", outputPath );
421
      final var task = new TypesetTask();
422
      final var time = currentTimeMillis();
423
      final var success = task.typeset();
424
425
      clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
426
            outputPath, since( time )
427
      );
428
    }
429
    else {
430
      throw new TypesetterNotFoundException( TYPESETTER.toString() );
431
    }
432
  }
433
434
  private Path getInputPath() {
435
    return mMutator.mInputPath;
436
  }
437
438
  private Path getOutputPath() {
439
    return mMutator.mOutputPath;
440
  }
441
442
  private Path getThemePath() {
443
    return mMutator.mThemePath;
444
  }
445
446
  private String getThemeName() {
447
    return mMutator.mThemeName;
448
  }
449
450
  /**
451
   * Answers whether logs and other files should be deleted upon error. The
452
   * log files are useful for debugging.
453
   *
454
   * @return {@code true} to delete generated files.
455
   */
456
  public boolean autoclean() {
457
    return mMutator.mAutoclean;
4
import com.keenwrite.collections.BoundedCache;
5
import com.keenwrite.io.SysFile;
6
import com.keenwrite.util.GenericBuilder;
7
8
import java.io.*;
9
import java.nio.file.NoSuchFileException;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Scanner;
15
import java.util.concurrent.Callable;
16
import java.util.regex.Pattern;
17
18
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static java.lang.ProcessBuilder.Redirect.DISCARD;
21
import static java.lang.String.format;
22
import static java.lang.System.currentTimeMillis;
23
import static java.lang.System.getProperty;
24
import static java.nio.file.Files.*;
25
import static java.util.Arrays.asList;
26
import static java.util.concurrent.TimeUnit.*;
27
import static org.apache.commons.io.FilenameUtils.removeExtension;
28
29
/**
30
 * Responsible for invoking an executable to typeset text. This will
31
 * construct suitable command-line arguments to invoke the typesetting engine.
32
 */
33
public class Typesetter {
34
  private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
35
36
  private final Mutator mMutator;
37
38
  public static GenericBuilder<Mutator, Typesetter> builder() {
39
    return GenericBuilder.of( Mutator::new, Typesetter::new );
40
  }
41
42
  public static final class Mutator {
43
    private Path mInputPath;
44
    private Path mOutputPath;
45
    private Path mThemePath;
46
    private boolean mAutoClean;
47
48
    /**
49
     * @param inputPath The input document to typeset.
50
     */
51
    public void setInputPath( final Path inputPath ) {
52
      mInputPath = inputPath;
53
    }
54
55
    /**
56
     * @param outputPath Path to the finished typeset document to create.
57
     */
58
    public void setOutputPath( final Path outputPath ) {
59
      mOutputPath = outputPath;
60
    }
61
62
    /**
63
     * @param themePath Fully qualified path to the theme directory, which
64
     *                  ends with the selected theme name.
65
     */
66
    public void setThemePath( final Path themePath ) {
67
      mThemePath = themePath;
68
    }
69
70
    /**
71
     * @see #setThemePath(Path)
72
     */
73
    public void setThemePath( final File themePath ) {
74
      setThemePath( themePath.toPath() );
75
    }
76
77
    /**
78
     * @param autoClean {@code true} to remove all temporary files after
79
     *                  typesetter produces a PDF file.
80
     */
81
    public void setAutoClean( final boolean autoClean ) {
82
      mAutoClean = autoClean;
83
    }
84
  }
85
86
  public static boolean canRun() {
87
    return TYPESETTER.canRun();
88
  }
89
90
  /**
91
   * Calculates the time that has elapsed from the current time to the
92
   * given moment in time.
93
   *
94
   * @param start The starting time, which should be before the current time.
95
   * @return A human-readable formatted time.
96
   * @see #asElapsed(long)
97
   */
98
  private static String since( final long start ) {
99
    return asElapsed( currentTimeMillis() - start );
100
  }
101
102
  /**
103
   * Converts an elapsed time to a human-readable format (hours, minutes,
104
   * seconds, and milliseconds).
105
   *
106
   * @param elapsed An elapsed time, in milliseconds.
107
   * @return Human-readable elapsed time.
108
   */
109
  private static String asElapsed( final long elapsed ) {
110
    final var hours = MILLISECONDS.toHours( elapsed );
111
    final var eHours = elapsed - HOURS.toMillis( hours );
112
    final var minutes = MILLISECONDS.toMinutes( eHours );
113
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
114
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
115
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
116
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
117
118
    return format( "%02d:%02d:%02d.%03d",
119
                   hours, minutes, seconds, milliseconds );
120
  }
121
122
  /**
123
   * Launches a task to typeset a document.
124
   */
125
  private class TypesetTask implements Callable<Boolean> {
126
    private final List<String> mArgs = new ArrayList<>();
127
128
    /**
129
     * Working directory must be set because ConTeXt cannot write the
130
     * result to an arbitrary location.
131
     */
132
    private final Path mDirectory;
133
134
    private TypesetTask() {
135
      final var parentDir = getOutputPath().getParent();
136
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
137
    }
138
139
    /**
140
     * Initializes ConTeXt, which means creating the cache directory if it
141
     * doesn't already exist. The theme entry point must be named 'main.tex'.
142
     *
143
     * @return {@code true} if the cache directory exists.
144
     */
145
    private boolean reinitialize() {
146
      final var filename = getOutputPath().getFileName();
147
      final var theme = getThemePath();
148
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
149
150
      // Ensure invoking multiple times will load the correct arguments.
151
      mArgs.clear();
152
      mArgs.add( TYPESETTER.getName() );
153
154
      if( cacheExists ) {
155
        mArgs.add( "--autogenerate" );
156
        mArgs.add( "--script" );
157
        mArgs.add( "mtx-context" );
158
        mArgs.add( "--batchmode" );
159
        mArgs.add( "--nonstopmode" );
160
        mArgs.add( "--purgeall" );
161
        mArgs.add( "--path='" + theme + "'" );
162
        mArgs.add( "--environment='main'" );
163
        mArgs.add( "--result='" + filename + "'" );
164
        mArgs.add( getInputPath().toString() );
165
166
        final var sb = new StringBuilder( 128 );
167
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
168
        clue( sb.toString() );
169
      }
170
      else {
171
        mArgs.add( "--generate" );
172
      }
173
174
      return cacheExists;
175
    }
176
177
    /**
178
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first
179
     * try. If the cache directory doesn't exist, attempt to create it, then
180
     * call ConTeXt to generate the PDF. This is brittle because if the
181
     * directory is empty, or not populated with cached data, a false positive
182
     * will be returned, resulting in no PDF being created.
183
     *
184
     * @return {@code true} if the document was typeset successfully.
185
     * @throws IOException          If the process could not be started.
186
     * @throws InterruptedException If the process was killed.
187
     */
188
    private boolean typeset() throws IOException, InterruptedException {
189
      return reinitialize() ? call() : call() && reinitialize() && call();
190
    }
191
192
    @Override
193
    public Boolean call() throws IOException, InterruptedException {
194
      final var stdout = new BoundedCache<String, String>( 150 );
195
      final var builder = new ProcessBuilder( mArgs );
196
      builder.directory( mDirectory.toFile() );
197
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
198
199
      // Without redirecting (or draining) stderr, the command may not
200
      // terminate successfully.
201
      builder.redirectError( DISCARD );
202
203
      final var process = builder.start();
204
      final var stream = process.getInputStream();
205
206
      // Reading from stdout allows slurping page numbers while generating.
207
      final var listener = new PaginationListener( stream, stdout );
208
      listener.start();
209
210
      // Even though the process has completed, there may be incomplete I/O.
211
      process.waitFor();
212
213
      // Allow time for any incomplete I/O to take place.
214
      process.waitFor( 1, SECONDS );
215
216
      final var exit = process.exitValue();
217
      process.destroy();
218
219
      // If there was an error, the typesetter will leave behind log, pdf, and
220
      // error files.
221
      if( exit > 0 ) {
222
        final var xmlName = getInputPath().getFileName().toString();
223
        final var srcName = getOutputPath().getFileName().toString();
224
        final var logName = newExtension( xmlName, ".log" );
225
        final var errName = newExtension( xmlName, "-error.log" );
226
        final var pdfName = newExtension( xmlName, ".pdf" );
227
        final var tuaName = newExtension( xmlName, ".tua" );
228
        final var badName = newExtension( srcName, ".log" );
229
230
        log( badName );
231
        log( logName );
232
        log( errName );
233
        log( stdout.keySet().stream().toList() );
234
235
        // Users may opt to keep these files around for debugging purposes.
236
        if( autoclean() ) {
237
          deleteIfExists( logName );
238
          deleteIfExists( errName );
239
          deleteIfExists( pdfName );
240
          deleteIfExists( badName );
241
          deleteIfExists( tuaName );
242
        }
243
      }
244
245
      // Exit value for a successful invocation of the typesetter. This value
246
      // value is returned when creating the cache on the first run as well as
247
      // creating PDFs on subsequent runs (after the cache has been created).
248
      // Users don't care about exit codes, only whether the PDF was generated.
249
      return exit == 0;
250
    }
251
252
    private Path newExtension( final String baseName, final String ext ) {
253
      return getOutputPath().resolveSibling( removeExtension( baseName ) + ext );
254
    }
255
256
    /**
257
     * Fires a status message for each line in the given file. The file format
258
     * is somewhat machine-readable, but no effort beyond line splitting is
259
     * made to parse the text.
260
     *
261
     * @param path Path to the file containing error messages.
262
     */
263
    private void log( final Path path ) throws IOException {
264
      if( exists( path ) ) {
265
        log( readAllLines( path ) );
266
      }
267
    }
268
269
    private void log( final List<String> lines ) {
270
      final var splits = new ArrayList<String>( lines.size() * 2 );
271
272
      for( final var line : lines ) {
273
        splits.addAll( asList( line.split( "\\\\n" ) ) );
274
      }
275
276
      clue( splits );
277
    }
278
279
    /**
280
     * Returns the location of the cache directory.
281
     *
282
     * @return A fully qualified path to the location to store temporary
283
     * files between typesetting runs.
284
     */
285
    private java.io.File getCacheDir() {
286
      final var temp = getProperty( "java.io.tmpdir" );
287
      final var cache = Path.of( temp, "luatex-cache" );
288
      return cache.toFile();
289
    }
290
291
    /**
292
     * Answers whether the given directory is empty. The typesetting software
293
     * creates a non-empty directory by default. The return value from this
294
     * method is a proxy to answering whether the typesetter has been run for
295
     * the first time or not.
296
     *
297
     * @param path The directory to check for emptiness.
298
     * @return {@code true} if the directory is empty.
299
     */
300
    private boolean isEmpty( final Path path ) {
301
      try( final var stream = newDirectoryStream( path ) ) {
302
        return !stream.iterator().hasNext();
303
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
304
        // A missing directory means it doesn't exist, ergo is empty.
305
        return true;
306
      } catch( final IOException ex ) {
307
        throw new RuntimeException( ex );
308
      }
309
    }
310
  }
311
312
  /**
313
   * Responsible for parsing the output from the typesetting engine and
314
   * updating the status bar to provide assurance that typesetting is
315
   * executing.
316
   *
317
   * <p>
318
   * Example lines written to standard output:
319
   * </p>
320
   * <pre>{@code
321
   * pages           > flushing realpage 15, userpage 15, subpage 15
322
   * pages           > flushing realpage 16, userpage 16, subpage 16
323
   * pages           > flushing realpage 1, userpage 1, subpage 1
324
   * pages           > flushing realpage 2, userpage 2, subpage 2
325
   * }</pre>
326
   * <p>
327
   * The lines are parsed; the first number is displayed in a status bar
328
   * message.
329
   * </p>
330
   */
331
  private static class PaginationListener extends Thread {
332
    private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
333
334
    private final InputStream mInputStream;
335
336
    private final Map<String, String> mCache;
337
338
    public PaginationListener(
339
      final InputStream in, final Map<String, String> cache ) {
340
      mInputStream = in;
341
      mCache = cache;
342
    }
343
344
    @Override
345
    public void run() {
346
      try( final var reader = createReader( mInputStream ) ) {
347
        int pageCount = 1;
348
        int passCount = 1;
349
        int pageTotal = 0;
350
        String line;
351
352
        while( (line = reader.readLine()) != null ) {
353
          mCache.put( line, "" );
354
355
          if( line.startsWith( "pages" ) ) {
356
            // The bottleneck will be the typesetting engine writing to stdout,
357
            // not the parsing of stdout.
358
            final var scanner = new Scanner( line ).useDelimiter( DIGITS );
359
            final var digits = scanner.next();
360
            final var page = Integer.parseInt( digits );
361
362
            // If the page number is less than the previous page count, it
363
            // means that the typesetting engine has started another pass.
364
            if( page < pageCount ) {
365
              passCount++;
366
              pageTotal = pageCount;
367
            }
368
369
            pageCount = page;
370
371
            // Inform the user of pages being typeset.
372
            clue( "Main.status.typeset.page",
373
                  pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
374
            );
375
          }
376
        }
377
      } catch( final IOException ex ) {
378
        clue( ex );
379
        throw new RuntimeException( ex );
380
      }
381
    }
382
383
    private BufferedReader createReader( final InputStream inputStream ) {
384
      return new BufferedReader( new InputStreamReader( inputStream ) );
385
    }
386
  }
387
388
  /**
389
   * Creates a new {@link Typesetter} instance capable of configuring the
390
   * typesetter used to generate a typeset document.
391
   */
392
  private Typesetter( final Mutator mutator ) {
393
    assert mutator != null;
394
395
    mMutator = mutator;
396
  }
397
398
  /**
399
   * This will typeset the document using a new process. The return value only
400
   * indicates whether the typesetter exists, not whether the typesetting was
401
   * successful.
402
   *
403
   * @throws IOException                 If the process could not be started.
404
   * @throws InterruptedException        If the process was killed.
405
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
406
   */
407
  public void typeset()
408
    throws IOException, InterruptedException, TypesetterNotFoundException {
409
    if( TYPESETTER.canRun() ) {
410
      final var outputPath = getOutputPath();
411
412
      clue( "Main.status.typeset.began", outputPath );
413
      final var task = new TypesetTask();
414
      final var time = currentTimeMillis();
415
      final var success = task.typeset();
416
417
      clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
418
            outputPath, since( time )
419
      );
420
    }
421
    else {
422
      throw new TypesetterNotFoundException( TYPESETTER.toString() );
423
    }
424
  }
425
426
  private Path getInputPath() {
427
    return mMutator.mInputPath;
428
  }
429
430
  private Path getOutputPath() {
431
    return mMutator.mOutputPath;
432
  }
433
434
  private Path getThemePath() {
435
    return mMutator.mThemePath;
436
  }
437
438
  /**
439
   * Answers whether logs and other files should be deleted upon error. The
440
   * log files are useful for debugging.
441
   *
442
   * @return {@code true} to delete generated files.
443
   */
444
  public boolean autoclean() {
445
    return mMutator.mAutoClean;
458446
  }
459447
}
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
1010
import com.keenwrite.editors.markdown.HyperlinkModel;
1111
import com.keenwrite.editors.markdown.LinkVisitor;
12
import com.keenwrite.events.CaretMovedEvent;
1213
import com.keenwrite.events.ExportFailedEvent;
1314
import com.keenwrite.preferences.PreferencesController;
...
109110
    } );
110111
111
    // When the active text editor changes, update the haystack.
112
    // When the active text editor changes ...
112113
    mMainPane.textEditorProperty().addListener(
113
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
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
      }
114123
    );
115124
  }
A src/main/java/com/keenwrite/ui/cells/AltTableCell.java
1
package com.keenwrite.ui.cells;
2
3
import javafx.scene.control.cell.TextFieldTableCell;
4
import javafx.util.StringConverter;
5
6
public class AltTableCell<S, T> extends TextFieldTableCell<S, T> {
7
  public AltTableCell( final StringConverter<T> converter ) {
8
    super( converter );
9
10
    assert converter != null;
11
12
    new CellEditor(
13
      input -> commitEdit( getConverter().fromString( input ) ),
14
      graphicProperty()
15
    );
16
  }
17
}
118
A src/main/java/com/keenwrite/ui/cells/AltTreeCell.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.cells;
3
4
import javafx.scene.control.cell.TextFieldTreeCell;
5
import javafx.util.StringConverter;
6
7
/**
8
 * Responsible for enhancing the existing cell behaviour with fairly common
9
 * functionality, including commit on focus loss and Enter to commit.
10
 *
11
 * @param <T> The type of data stored by the tree.
12
 */
13
public class AltTreeCell<T> extends TextFieldTreeCell<T> {
14
  public AltTreeCell( final StringConverter<T> converter ) {
15
    super( converter );
16
17
    assert converter != null;
18
19
    new CellEditor(
20
      input -> commitEdit( getConverter().fromString( input ) ),
21
      graphicProperty()
22
    );
23
  }
24
}
125
A src/main/java/com/keenwrite/ui/cells/CellEditor.java
1
package com.keenwrite.ui.cells;
2
3
import javafx.beans.property.ObjectProperty;
4
import javafx.beans.property.Property;
5
import javafx.beans.property.SimpleStringProperty;
6
import javafx.beans.value.ChangeListener;
7
import javafx.beans.value.ObservableValue;
8
import javafx.event.EventHandler;
9
import javafx.scene.Node;
10
import javafx.scene.control.TableCell;
11
import javafx.scene.control.TextField;
12
import javafx.scene.control.TreeCell;
13
import javafx.scene.input.KeyEvent;
14
15
import java.util.function.Consumer;
16
17
import static javafx.application.Platform.runLater;
18
import static javafx.scene.input.KeyCode.ENTER;
19
import static javafx.scene.input.KeyCode.TAB;
20
import static javafx.scene.input.KeyEvent.KEY_RELEASED;
21
22
public class CellEditor {
23
  private FocusListener mFocusListener;
24
  private final KeyHandler mKeyHandler = new KeyHandler();
25
  private final Property<String> mInputText = new SimpleStringProperty();
26
  private final Consumer<String> mConsumer;
27
28
  /**
29
   * Responsible for accepting the text when users press the Enter or Tab key.
30
   */
31
  private class KeyHandler implements EventHandler<KeyEvent> {
32
    @Override
33
    public void handle( final KeyEvent event ) {
34
      if( event.getCode() == ENTER || event.getCode() == TAB ) {
35
        commitEdit();
36
        event.consume();
37
      }
38
    }
39
  }
40
41
  /**
42
   * Responsible for committing edits when focus is lost. This will also
43
   * deselect the input field when focus is gained so that typing text won't
44
   * overwrite the entire existing text.
45
   */
46
  private class FocusListener implements ChangeListener<Boolean> {
47
    private final TextField mInput;
48
49
    private FocusListener( final TextField input ) {
50
      mInput = input;
51
    }
52
53
    @Override
54
    public void changed(
55
      final ObservableValue<? extends Boolean> c,
56
      final Boolean endedFocus, final Boolean beganFocus ) {
57
58
      if( beganFocus ) {
59
        runLater( mInput::deselect );
60
      }
61
      else if( endedFocus ) {
62
        commitEdit();
63
      }
64
    }
65
  }
66
67
  /**
68
   * Generalized cell editor suitable for use with {@link TableCell} or
69
   * {@link TreeCell} instances.
70
   *
71
   * @param consumer        Converts the field input text to the required
72
   *                        data type.
73
   * @param graphicProperty Defines the graphical user input field.
74
   */
75
  public CellEditor(
76
    final Consumer<String> consumer,
77
    final ObjectProperty<Node> graphicProperty ) {
78
    assert consumer != null;
79
    mConsumer = consumer;
80
81
    init( graphicProperty );
82
  }
83
84
  private void init( final ObjectProperty<Node> graphicProperty ) {
85
    // When the text field is added as the graphics context, we hook into
86
    // the changed value to get a handle on the text field. From there it is
87
    // possible to add change the keyboard and focus behaviours.
88
    graphicProperty.addListener( ( c, o, n ) -> {
89
      if( o instanceof TextField ) {
90
        o.removeEventHandler( KEY_RELEASED, mKeyHandler );
91
        o.focusedProperty().removeListener( mFocusListener );
92
      }
93
94
      if( n instanceof final TextField input ) {
95
        n.addEventFilter( KEY_RELEASED, mKeyHandler );
96
        mInputText.bind( input.textProperty() );
97
        mFocusListener = new FocusListener( input );
98
        n.focusedProperty().addListener( mFocusListener );
99
      }
100
    } );
101
  }
102
103
  private void commitEdit() {
104
    mConsumer.accept( mInputText.getValue() );
105
  }
106
}
1107
D src/main/java/com/keenwrite/ui/common/CellEditor.java
1
package com.keenwrite.ui.common;
2
3
import javafx.beans.property.ObjectProperty;
4
import javafx.beans.property.Property;
5
import javafx.beans.property.SimpleStringProperty;
6
import javafx.beans.value.ChangeListener;
7
import javafx.beans.value.ObservableValue;
8
import javafx.event.EventHandler;
9
import javafx.scene.Node;
10
import javafx.scene.control.TableCell;
11
import javafx.scene.control.TextField;
12
import javafx.scene.control.TreeCell;
13
import javafx.scene.input.KeyEvent;
14
15
import java.util.function.Consumer;
16
17
import static javafx.application.Platform.runLater;
18
import static javafx.scene.input.KeyCode.ENTER;
19
import static javafx.scene.input.KeyCode.TAB;
20
import static javafx.scene.input.KeyEvent.KEY_RELEASED;
21
22
public class CellEditor {
23
  private FocusListener mFocusListener;
24
  private final KeyHandler mKeyHandler = new KeyHandler();
25
  private final Property<String> mInputText = new SimpleStringProperty();
26
  private final Consumer<String> mConsumer;
27
28
  /**
29
   * Responsible for accepting the text when users press the Enter or Tab key.
30
   */
31
  private class KeyHandler implements EventHandler<KeyEvent> {
32
    @Override
33
    public void handle( final KeyEvent event ) {
34
      if( event.getCode() == ENTER || event.getCode() == TAB ) {
35
        commitEdit();
36
        event.consume();
37
      }
38
    }
39
  }
40
41
  /**
42
   * Responsible for committing edits when focus is lost. This will also
43
   * deselect the input field when focus is gained so that typing text won't
44
   * overwrite the entire existing text.
45
   */
46
  private class FocusListener implements ChangeListener<Boolean> {
47
    private final TextField mInput;
48
49
    private FocusListener( final TextField input ) {
50
      mInput = input;
51
    }
52
53
    @Override
54
    public void changed(
55
      final ObservableValue<? extends Boolean> c,
56
      final Boolean endedFocus, final Boolean beganFocus ) {
57
58
      if( beganFocus ) {
59
        runLater( mInput::deselect );
60
      }
61
      else if( endedFocus ) {
62
        commitEdit();
63
      }
64
    }
65
  }
66
67
  /**
68
   * Generalized cell editor suitable for use with {@link TableCell} or
69
   * {@link TreeCell} instances.
70
   *
71
   * @param consumer        Converts the field input text to the required
72
   *                        data type.
73
   * @param graphicProperty Defines the graphical user input field.
74
   */
75
  public CellEditor(
76
    final Consumer<String> consumer,
77
    final ObjectProperty<Node> graphicProperty ) {
78
    assert consumer != null;
79
    mConsumer = consumer;
80
81
    init( graphicProperty );
82
  }
83
84
  private void init( final ObjectProperty<Node> graphicProperty ) {
85
    // When the text field is added as the graphics context, we hook into
86
    // the changed value to get a handle on the text field. From there it is
87
    // possible to add change the keyboard and focus behaviours.
88
    graphicProperty.addListener( ( c, o, n ) -> {
89
      if( o instanceof TextField ) {
90
        o.removeEventHandler( KEY_RELEASED, mKeyHandler );
91
        o.focusedProperty().removeListener( mFocusListener );
92
      }
93
94
      if( n instanceof final TextField input ) {
95
        n.addEventFilter( KEY_RELEASED, mKeyHandler );
96
        mInputText.bind( input.textProperty() );
97
        mFocusListener = new FocusListener( input );
98
        n.focusedProperty().addListener( mFocusListener );
99
      }
100
    } );
101
  }
102
103
  private void commitEdit() {
104
    mConsumer.accept( mInputText.getValue() );
105
  }
106
}
1071
D src/main/java/com/keenwrite/ui/listeners/CaretListener.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.listeners;
3
4
import com.keenwrite.Caret;
5
import com.keenwrite.editors.TextEditor;
6
import com.keenwrite.events.WordCountEvent;
7
import javafx.beans.property.ReadOnlyObjectProperty;
8
import javafx.beans.value.ChangeListener;
9
import javafx.beans.value.ObservableValue;
10
import javafx.scene.control.Label;
11
import javafx.scene.layout.VBox;
12
import org.greenrobot.eventbus.Subscribe;
13
14
import static com.keenwrite.events.Bus.register;
15
import static javafx.application.Platform.runLater;
16
import static javafx.geometry.Pos.BASELINE_CENTER;
17
18
/**
19
 * Responsible for updating the UI whenever the caret changes position.
20
 * Only one instance of {@link CaretListener} is allowed, which prevents
21
 * duplicate adds to the observable property.
22
 */
23
public class CaretListener extends VBox implements ChangeListener<Integer> {
24
25
  /**
26
   * Use an instance of {@link Label} for its built-in CSS style class.
27
   */
28
  private final Label mLineNumberText = new Label();
29
  private volatile Caret mCaret;
30
31
  /**
32
   * Approximate number of words in the document.
33
   */
34
  private volatile int mCount;
35
36
  public CaretListener( final ReadOnlyObjectProperty<TextEditor> editor ) {
37
    assert editor != null;
38
39
    setAlignment( BASELINE_CENTER );
40
    getChildren().add( mLineNumberText );
41
42
    editor.addListener( ( c, o, n ) -> {
43
      if( n != null ) {
44
        updateListener( n.getCaret() );
45
      }
46
    } );
47
48
    updateListener( editor.get().getCaret() );
49
    register( this );
50
  }
51
52
  /**
53
   * Called whenever the caret position changes.
54
   *
55
   * @param c The caret position property.
56
   * @param o The old caret position offset.
57
   * @param n The new caret position offset.
58
   */
59
  @Override
60
  public void changed(
61
    final ObservableValue<? extends Integer> c,
62
    final Integer o, final Integer n ) {
63
    updateLineNumber();
64
  }
65
66
  @Subscribe
67
  public void handle( final WordCountEvent event ) {
68
    mCount = event.getCount();
69
    updateLineNumber();
70
  }
71
72
  private void updateListener( final Caret caret ) {
73
    assert caret != null;
74
75
    final var property = caret.textOffsetProperty();
76
77
    property.removeListener( this );
78
    mCaret = caret;
79
    property.addListener( this );
80
    updateLineNumber();
81
  }
82
83
  private void updateLineNumber() {
84
    runLater(
85
      () -> mLineNumberText.setText( mCaret.toString() + " | " + mCount )
86
    );
87
  }
88
}
891
A src/main/java/com/keenwrite/ui/listeners/CaretStatus.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.listeners;
3
4
import com.keenwrite.editors.common.Caret;
5
import com.keenwrite.events.CaretMovedEvent;
6
import com.keenwrite.events.WordCountEvent;
7
import javafx.scene.control.Label;
8
import javafx.scene.layout.VBox;
9
import org.greenrobot.eventbus.Subscribe;
10
11
import static com.keenwrite.events.Bus.register;
12
import static javafx.application.Platform.runLater;
13
import static javafx.geometry.Pos.BASELINE_CENTER;
14
15
/**
16
 * Responsible for updating the UI whenever the caret changes position.
17
 * Only one instance of {@link CaretStatus} is allowed, which prevents
18
 * duplicate adds to the observable property.
19
 */
20
public class CaretStatus extends VBox {
21
22
  /**
23
   * Use an instance of {@link Label} for its built-in CSS style class.
24
   */
25
  private final Label mStatusText = new Label();
26
27
  /**
28
   * Contains caret position information within an editor.
29
   */
30
  private volatile Caret mCaret = Caret.builder().build();
31
32
  /**
33
   * Approximate number of words in the document.
34
   */
35
  private volatile int mCount;
36
37
  public CaretStatus() {
38
    setAlignment( BASELINE_CENTER );
39
    getChildren().add( mStatusText );
40
    register( this );
41
  }
42
43
  @Subscribe
44
  public void handle( final WordCountEvent event ) {
45
    mCount = event.getCount();
46
    updateStatus( mCaret, mCount );
47
  }
48
49
  @Subscribe
50
  public void handle( final CaretMovedEvent event ) {
51
    mCaret = event.getCaret();
52
    updateStatus( mCaret, mCount );
53
  }
54
55
  private void updateStatus( final Caret caret, final int count ) {
56
    assert caret != null;
57
    runLater( () -> mStatusText.setText( caret + " | " + count ) );
58
  }
59
}
160
D src/main/java/com/keenwrite/ui/table/AltTableCell.java
1
package com.keenwrite.ui.table;
2
3
import com.keenwrite.ui.common.CellEditor;
4
import javafx.scene.control.cell.TextFieldTableCell;
5
import javafx.util.StringConverter;
6
7
public class AltTableCell<S, T> extends TextFieldTableCell<S, T> {
8
  public AltTableCell( final StringConverter<T> converter ) {
9
    super( converter );
10
11
    assert converter != null;
12
13
    new CellEditor(
14
      input -> commitEdit( getConverter().fromString( input ) ),
15
      graphicProperty()
16
    );
17
  }
18
}
191
D src/main/java/com/keenwrite/ui/tree/AltTreeCell.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.tree;
3
4
import com.keenwrite.ui.common.CellEditor;
5
import javafx.scene.control.cell.TextFieldTreeCell;
6
import javafx.util.StringConverter;
7
8
/**
9
 * Responsible for enhancing the existing cell behaviour with fairly common
10
 * functionality, including commit on focus loss and Enter to commit.
11
 *
12
 * @param <T> The type of data stored by the tree.
13
 */
14
public class AltTreeCell<T> extends TextFieldTreeCell<T> {
15
  public AltTreeCell( final StringConverter<T> converter ) {
16
    super( converter );
17
18
    assert converter != null;
19
20
    new CellEditor(
21
      input -> commitEdit( getConverter().fromString( input ) ),
22
      graphicProperty()
23
    );
24
  }
25
}
261
M src/main/java/com/keenwrite/ui/tree/AltTreeView.java
22
package com.keenwrite.ui.tree;
33
4
import com.keenwrite.ui.cells.AltTreeCell;
45
import javafx.scene.control.TreeItem;
56
import javafx.scene.control.TreeView;
M src/test/java/com/keenwrite/preview/DiagramUrlGeneratorTest.java
33
import org.junit.jupiter.api.Test;
44
5
import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME;
56
import static com.keenwrite.preview.DiagramUrlGenerator.toUrl;
67
import static org.junit.jupiter.api.Assertions.assertEquals;
78
89
/**
910
 * Responsible for testing that images sent to the diagram server will render.
1011
 */
1112
class DiagramUrlGeneratorTest {
12
  private final static String SERVER_NAME = "kroki.io";
13
1413
  // @formatter:off
1514
  private final static String[] DIAGRAMS = new String[]{
...
4342
      final var text = DIAGRAMS[ i + 1 ];
4443
      final var expected = DIAGRAMS[ i + 2 ];
45
      final var actual = toUrl( SERVER_NAME, name, text );
44
      final var actual = toUrl( DIAGRAM_SERVER_NAME, name, text );
4645
4746
      assertEquals( expected, actual );
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
33
44
import com.keenwrite.AwaitFxExtension;
5
import com.keenwrite.Caret;
6
import com.keenwrite.preferences.Workspace;
5
import com.keenwrite.editors.common.Caret;
76
import com.keenwrite.processors.Processor;
87
import com.keenwrite.processors.ProcessorContext;
...
4039
@SuppressWarnings( "SameParameterValue" )
4140
public class ImageLinkExtensionTest {
42
  private static final Workspace sWorkspace = new Workspace(
43
    getResourceFile( "workspace.xml" ) );
44
4541
  private static final Map<String, String> IMAGES = new HashMap<>();
4642
...
147143
      .with( ProcessorContext.Mutator::setInputPath, inputPath )
148144
      .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX )
149
      .with( ProcessorContext.Mutator::setWorkspace, sWorkspace )
150145
      .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() )
151146
      .build();
...
173168
  private static String getResource( final String path ) {
174169
    return toUri( path ).toString();
175
  }
176
177
  private static File getResourceFile( final String path ) {
178
    return new File( getResource( path ) );
179170
  }
180171
}