Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M BUILD.md
77
Download and install the following software packages:
88
9
* [JDK 17](https://bell-sw.com/pages/downloads/?version=java-17) (Full JDK + JavaFX)
10
* [Gradle 7.2](https://gradle.org/releases)
11
* [Git 2.33](https://git-scm.com/downloads)
9
* [JDK 18](https://bell-sw.com/pages/downloads/?version=java-18) (Full JDK + JavaFX)
10
* [Gradle 7.3](https://gradle.org/releases) (build fails on 7.5.1)
11
* [Git 2.37.1](https://git-scm.com/downloads)
1212
1313
## Repository
M build.gradle
11
plugins {
22
  id 'application'
3
  id 'org.openjfx.javafxplugin' version '0.0.10'
4
  id 'com.palantir.git-version' version '0.14.0'
3
  id 'org.openjfx.javafxplugin' version '0.0.13'
4
  id 'com.palantir.git-version' version '0.15.0'
55
}
66
77
repositories {
88
  mavenCentral()
9
10
  maven {
11
    url 'https://oss.sonatype.org/content/repositories/snapshots/'
12
  }
139
14
  maven {
15
    url "https://nexus.bedatadriven.com/content/groups/public"
16
  }
10
  maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
11
  maven { url 'https://nexus.bedatadriven.com/content/groups/public' }
1712
}
1813
1914
// Assume a cross-platform überjar unless targetOs is set.
20
String[] os = ["win", "mac", "linux"]
15
String[] os = ['win', 'mac', 'linux']
2116
22
if (project.hasProperty('targetOs')) {
23
  if ("windows" == targetOs) {
17
if (project.hasProperty( 'targetOs' )) {
18
  if ('windows' == targetOs) {
2419
    os = ["win"]
2520
  } else {
2621
    os = [targetOs]
2722
  }
2823
}
2924
3025
def moduleSecurity = [
31
    "--add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED",
32
    "--add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED",
33
    "--add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED",
34
    "--add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED",
35
    "--add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED",
36
    "--add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED",
37
    "--add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
38
    "--add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED",
39
    "--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED",
40
    "--add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED",
41
    "--add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED",
42
    "--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED",
26
    '--add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED',
27
    '--add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED',
28
    '--add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED',
29
    '--add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED',
30
    '--add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED',
31
    '--add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED',
32
    '--add-exports=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED',
33
    '--add-exports=javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED',
34
    '--add-exports=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED',
35
    '--add-exports=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED',
36
    '--add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED',
37
    '--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED',
4338
]
4439
4540
javafx {
46
  version = "17"
41
  version = '18'
4742
  modules = ['javafx.controls', 'javafx.swing']
4843
  configuration = 'compileOnly'
4944
}
5045
5146
dependencies {
52
  def v_junit = '5.8.2'
47
  def v_junit = '5.9.0'
5348
  def v_flexmark = '0.64.0'
5449
  def v_jackson = '2.13.3'
5550
  def v_batik = '1.14'
56
  def v_wheatsheaf = '2.0.1'
5751
5852
  // JavaFX
59
  // TODO: Reinstate when JDK 17-compatible release is published
60
  //implementation 'org.controlsfx:controlsfx:11.1.0'
53
  implementation 'org.controlsfx:controlsfx:11.1.1'
6154
  implementation 'org.fxmisc.richtext:richtextfx:0.10.9'
6255
  implementation 'org.fxmisc.flowless:flowless:0.6.10'
6356
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
6457
  implementation 'com.miglayout:miglayout-javafx:11.0'
65
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.9.0'
66
67
  // Pure JavaFX File Chooser
68
  // TODO: Reinstate when file picker performance increases
69
//  implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf:${v_wheatsheaf}"
70
//  implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.api:${v_wheatsheaf}"
71
//  implementation "com.io7m.jwheatsheaf:com.io7m.jwheatsheaf.ui:${v_wheatsheaf}"
58
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.9.1'
7259
7360
  // Markdown
...
9077
9178
  // HTML parsing and rendering
92
  implementation 'org.jsoup:jsoup:1.14.3'
93
  // TODO: Wait for https://github.com/flyingsaucerproject/flyingsaucer/pull/170
79
  implementation 'org.jsoup:jsoup:1.15.2'
80
  // TODO: https://github.com/flyingsaucerproject/flyingsaucer/pull/170
9481
  //implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22'
9582
...
116103
  // Misc.
117104
  implementation 'org.ahocorasick:ahocorasick:0.6.3'
118
  implementation 'org.apache.commons:commons-configuration2:2.7'
105
  implementation 'org.apache.commons:commons-configuration2:2.8.0'
119106
  implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3'
120107
  implementation 'javax.validation:validation-api:2.0.1.Final'
121108
  implementation 'org.greenrobot:eventbus-java:3.3.1'
122
123
  implementation 'org.apache.commons:commons-configuration2:2.7'
124
  //noinspection GradlePackageUpdate
125109
  implementation 'commons-beanutils:commons-beanutils:1.9.4'
126110
127111
  // Command-line parsing
128112
  implementation 'info.picocli:picocli:4.6.3'
129113
130114
  // Spelling, TeX, Docking, KeenQuotes
131
  implementation fileTree(include: ['**/*.jar'], dir: 'libs')
115
  implementation fileTree( include: ['**/*.jar'], dir: 'libs' )
132116
133117
  def fx = ['controls', 'graphics', 'fxml', 'swing']
134118
135119
  fx.each { fxitem ->
136120
    os.each { ositem ->
137121
      runtimeOnly "org.openjfx:javafx-${fxitem}:${javafx.version}:${ositem}"
138122
    }
139123
  }
140124
141
  testImplementation "org.testfx:testfx-junit5:4.0.16-alpha"
125
  testImplementation 'org.testfx:testfx-junit5:4.0.16-alpha'
142126
  testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}"
143127
  testImplementation "org.junit.jupiter:junit-jupiter-params:${v_junit}"
144128
  testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
145129
}
146130
147131
compileJava {
148
  options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
132
  options.compilerArgs << '-Xlint:unchecked' << '-Xlint:deprecation'
149133
}
150134
151135
def resourceDir = sourceSets.main.resources.srcDirs[0]
152136
153137
final Properties config = new Properties()
154
final File configFile = file("${resourceDir}/bootstrap.properties")
155
final FileInputStream configStream = new FileInputStream(configFile)
156
config.load(configStream)
138
final File configFile = file( "${resourceDir}/bootstrap.properties" )
139
final FileInputStream configStream = new FileInputStream( configFile )
140
config.load( configStream )
157141
configStream.close()
158142
159
final String applicationName = config.get("application.title").toString().toLowerCase()
143
final String applicationName = config.get( 'application.title' ).toString().toLowerCase()
160144
final String applicationClass = "com.${applicationName}.Launcher"
161145
162146
application {
163
  mainClass.set(applicationClass)
147
  mainClass.set( applicationClass )
164148
  applicationDefaultJvmArgs = moduleSecurity
165149
}
166150
167151
version = gitVersion()
168152
169
final File propertiesFile = new File("${resourceDir}/com/${applicationName}/app.properties")
170
propertiesFile.write("application.version=${version}")
153
final File p = new File( "${resourceDir}/com/${applicationName}/app.properties" )
154
p.write( "application.version=${version}" )
171155
172156
jar {
...
180164
181165
  from {
182
    (configurations.runtimeClasspath.findAll { !it.path.endsWith(".pom") }).collect {
183
      it.isDirectory() ? it : zipTree(it)
184
    }
166
    (configurations.runtimeClasspath.findAll { !it.path.endsWith( ".pom" ) })
167
        .collect { it.isDirectory() ? it : zipTree( it ) }
185168
  }
186169
...
195178
    contents {
196179
      from { ['LICENSE.md', 'README.md'] }
197
      into('images') {
180
      into( 'images' ) {
198181
        from { 'images' }
199182
      }
200183
    }
201184
  }
202185
}
203186
204187
test {
205188
  useJUnitPlatform()
206
207
  doFirst {
208
    jvmArgs = moduleSecurity
209
  }
210189
211
  testLogging {
212
    exceptionFormat = 'full'
213
  }
190
  doFirst { jvmArgs = moduleSecurity }
191
  testLogging { exceptionFormat = 'full' }
214192
}
215193
M installer.sh
1313
readonly FILE_APP_JAR="${APP_NAME}.jar"
1414
15
# For GTK version, see https://bugs.openjdk.java.net/browse/JDK-8156779
1615
readonly OPT_JAVA=$(cat << END_OF_ARGS
17
-Djdk.gtk.version=2 \
1816
--add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \
1917
--add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \
...
3331
ARG_JAVA_OS="linux"
3432
ARG_JAVA_ARCH="amd64"
35
ARG_JAVA_VERSION="17.0.1"
36
ARG_JAVA_UPDATE="12"
33
ARG_JAVA_VERSION="18.0.2"
34
ARG_JAVA_UPDATE="10"
3735
ARG_JAVA_DIR="java"
3836
D libs/controlsfx-11.1.1.jar
Binary file
D libs/jwheatsheaf/com.io7m.jaffirm.core-3.0.5-SNAPSHOT.jar
Binary file
D libs/jwheatsheaf/com.io7m.junreachable.core-3.0.1-SNAPSHOT.jar
Binary file
D libs/jwheatsheaf/com.io7m.jwheatsheaf.api-3.0.0-SNAPSHOT.jar
Binary file
D libs/jwheatsheaf/com.io7m.jwheatsheaf.oxygen-3.0.0-SNAPSHOT.jar
Binary file
D libs/jwheatsheaf/com.io7m.jwheatsheaf.ui-3.0.0-SNAPSHOT.jar
Binary file
M src/main/java/com/keenwrite/Launcher.java
1111
import java.util.Properties;
1212
import java.util.function.Consumer;
13
import java.util.logging.LogManager;
1413
1514
import static com.keenwrite.Bootstrap.*;
1615
import static com.keenwrite.security.PermissiveCertificate.installTrustManager;
1716
import static java.lang.String.format;
18
import static picocli.CommandLine.*;
19
import static picocli.CommandLine.UnmatchedArgumentException.*;
17
import static picocli.CommandLine.IParameterExceptionHandler;
18
import static picocli.CommandLine.ParameterException;
19
import static picocli.CommandLine.UnmatchedArgumentException.printSuggestions;
2020
2121
/**
...
202202
      }
203203
      else {
204
        disableLogging();
204
        MainApp.disableLogging();
205205
      }
206206
...
216216
      log( t );
217217
    }
218
  }
219
220
  /**
221
   * Suppress writing to standard error, suppresses writing log messages.
222
   */
223
  private static void disableLogging() {
224
    LogManager.getLogManager().reset();
225
    System.err.close();
226218
  }
227219
M src/main/java/com/keenwrite/MainApp.java
1313
import org.greenrobot.eventbus.Subscribe;
1414
15
import java.io.PrintStream;
1516
import java.util.function.BooleanSupplier;
17
import java.util.logging.LogManager;
1618
1719
import static com.keenwrite.Bootstrap.APP_TITLE;
...
3436
  private Workspace mWorkspace;
3537
  private MainScene mMainScene;
38
39
  /**
40
   * Suppress writing to standard error, suppresses writing log messages.
41
   */
42
  static void disableLogging() {
43
    LogManager.getLogManager().reset();
44
    stderrDisable();
45
  }
46
47
  /**
48
   * TODO: Delete this after JavaFX/GTK 3 no longer barfs useless warnings.
49
   */
50
  private static void stderrDisable() {
51
    System.err.close();
52
  }
53
54
  /**
55
   * TODO: Delete this after JavaFX/GTK 3 no longer barfs useless warnings.
56
   */
57
  @SuppressWarnings( "SameParameterValue" )
58
  private static void stderrRedirect( final PrintStream stream ) {
59
    System.setErr( stream );
60
  }
3661
3762
  /**
...
94119
  @Override
95120
  public void start( final Stage stage ) {
121
    stderrDisable();
122
96123
    // Must be instantiated after the UI is initialized (i.e., not in main)
97124
    // because it interacts with GUI properties.
...
105132
106133
    stage.show();
134
135
    stderrRedirect( System.out );
136
107137
    register( this );
108138
  }
A src/main/java/com/keenwrite/io/SilentPrintStream.java
1
package com.keenwrite.io;
2
3
import java.io.OutputStream;
4
import java.io.PrintStream;
5
6
/**
7
 * Responsible for silently discarding all data written to the stream.
8
 */
9
public final class SilentPrintStream extends PrintStream {
10
11
  private final static class SilentOutputStream extends OutputStream {
12
    public void write( final int b ) {}
13
  }
14
15
  public SilentPrintStream() {
16
    super( new SilentOutputStream() );
17
  }
18
}
119
M src/main/java/com/keenwrite/security/PermissiveCertificate.java
1212
 * Responsible for trusting all certificate chains. The purpose of this class
1313
 * is to work-around certificate issues caused by software that blocks
14
 * HTTP requests. For example, zscaler may block HTTP requests to kroki.io
14
 * HTTP requests. For example, Zscaler may block HTTP requests to kroki.io
1515
 * when generating diagrams.
1616
 */
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
4747
import static com.keenwrite.preferences.AppKeys.*;
4848
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
49
import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
50
import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
51
import static com.keenwrite.util.FileWalker.walk;
52
import static java.nio.file.Files.readString;
53
import static java.nio.file.Files.writeString;
54
import static java.util.concurrent.Executors.newFixedThreadPool;
55
import static javafx.application.Platform.runLater;
56
import static javafx.event.Event.fireEvent;
57
import static javafx.scene.control.Alert.AlertType.INFORMATION;
58
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
59
import static org.apache.commons.io.FilenameUtils.getExtension;
60
61
/**
62
 * Responsible for abstracting how functionality is mapped to the application.
63
 * This allows users to customize accelerator keys and will provide pluggable
64
 * functionality so that different text markup languages can change documents
65
 * using their respective syntax.
66
 */
67
public final class GuiCommands {
68
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
69
70
  private static final String STYLE_SEARCH = "search";
71
72
  /**
73
   * Sci-fi genres, which are can be longer than other genres, typically fall
74
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
75
   * memory when concatenating files together when exporting novels.
76
   */
77
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
78
79
  /**
80
   * When an action is executed, this is one of the recipients.
81
   */
82
  private final MainPane mMainPane;
83
84
  private final MainScene mMainScene;
85
86
  private final LogView mLogView;
87
88
  /**
89
   * Tracks finding text in the active document.
90
   */
91
  private final SearchModel mSearchModel;
92
93
  public GuiCommands( final MainScene scene, final MainPane pane ) {
94
    mMainScene = scene;
95
    mMainPane = pane;
96
    mLogView = new LogView();
97
    mSearchModel = new SearchModel();
98
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
99
      final var editor = getActiveTextEditor();
100
101
      // Clear highlighted areas before highlighting a new region.
102
      if( o != null ) {
103
        editor.unstylize( STYLE_SEARCH );
104
      }
105
106
      if( n != null ) {
107
        editor.moveTo( n.getStart() );
108
        editor.stylize( n, STYLE_SEARCH );
109
      }
110
    } );
111
112
    // When the active text editor changes ...
113
    mMainPane.textEditorProperty().addListener(
114
      ( c, o, n ) -> {
115
        // ... update the haystack.
116
        mSearchModel.search( getActiveTextEditor().getText() );
117
118
        // ... update the status bar with the current caret position.
119
        if( n != null ) {
120
          CaretMovedEvent.fire( n.getCaret() );
121
        }
122
      }
123
    );
124
  }
125
126
  public void file_new() {
127
    getMainPane().newTextEditor();
128
  }
129
130
  public void file_open() {
131
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
132
  }
133
134
  public void file_close() {
135
    getMainPane().close();
136
  }
137
138
  public void file_close_all() {
139
    getMainPane().closeAll();
140
  }
141
142
  public void file_save() {
143
    getMainPane().save();
144
  }
145
146
  public void file_save_as() {
147
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
148
  }
149
150
  public void file_save_all() {
151
    getMainPane().saveAll();
152
  }
153
154
  /**
155
   * Converts the actively edited file in the given file format.
156
   *
157
   * @param format The destination file format.
158
   */
159
  private void file_export( final ExportFormat format ) {
160
    file_export( format, false );
161
  }
162
163
  /**
164
   * Converts one or more files into the given file format. If {@code dir}
165
   * is set to true, this will first append all files in the same directory
166
   * as the actively edited file.
167
   *
168
   * @param format The destination file format.
169
   * @param dir    Export all files in the actively edited file's directory.
170
   */
171
  private void file_export( final ExportFormat format, final boolean dir ) {
172
    final var main = getMainPane();
173
    final var editor = main.getTextEditor();
174
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
175
    final var filename = format.toExportFilename( editor.getPath() );
176
    final var selection = pickFiles(
177
      Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
178
        ? filename
179
        : exported.get(), FILE_EXPORT
180
    );
181
182
    selection.ifPresent( ( files ) -> {
183
      editor.save();
184
185
      final var file = files.get( 0 );
186
      final var path = file.toPath();
187
      final var document = dir ? append( editor ) : editor.getText();
188
      final var context = main.createProcessorContext( path, format );
189
190
      final var task = new Task<Path>() {
191
        @Override
192
        protected Path call() throws Exception {
193
          final var chain = createProcessors( context );
194
          final var export = chain.apply( document );
195
196
          // Processors can export binary files. In such cases, processors
197
          // return null to prevent further processing.
198
          return export == null ? null : writeString( path, export );
199
        }
200
      };
201
202
      task.setOnSucceeded(
203
        e -> {
204
          // Remember the exported file name for next time.
205
          exported.setValue( file );
206
207
          final var result = task.getValue();
208
209
          // Binary formats must notify users of success independently.
210
          if( result != null ) {
211
            clue( "Main.status.export.success", result );
212
          }
213
        }
214
      );
215
216
      task.setOnFailed( e -> {
217
        final var ex = task.getException();
218
        clue( ex );
219
220
        if( ex instanceof TypeNotPresentException ) {
221
          fireExportFailedEvent();
222
        }
223
      } );
224
225
      sExecutor.execute( task );
226
    } );
227
  }
228
229
  /**
230
   * @param dir {@code true} means to export all files in the active file
231
   *            editor's directory; {@code false} means to export only the
232
   *            actively edited file.
233
   */
234
  private void file_export_pdf( final boolean dir ) {
235
    final var workspace = getWorkspace();
236
    final var themes = workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
237
    final var theme = workspace.stringProperty(
238
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
239
240
    if( Typesetter.canRun() ) {
241
      // If the typesetter is installed, allow the user to select a theme. If
242
      // the themes aren't installed, a status message will appear.
243
      if( ThemePicker.choose( themes, theme ) ) {
244
        file_export( APPLICATION_PDF, dir );
245
      }
246
    }
247
    else {
248
      fireExportFailedEvent();
249
    }
250
  }
251
252
  public void file_export_pdf() {
253
    file_export_pdf( false );
254
  }
255
256
  public void file_export_pdf_dir() {
257
    file_export_pdf( true );
258
  }
259
260
  public void file_export_html_svg() {
261
    file_export( HTML_TEX_SVG );
262
  }
263
264
  public void file_export_html_tex() {
265
    file_export( HTML_TEX_DELIMITED );
266
  }
267
268
  public void file_export_xhtml_tex() {
269
    file_export( XHTML_TEX );
270
  }
271
272
  private void fireExportFailedEvent() {
273
    runLater( ExportFailedEvent::fire );
274
  }
275
276
  public void file_exit() {
277
    final var window = getWindow();
278
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
279
  }
280
281
  public void edit_undo() {
282
    getActiveTextEditor().undo();
283
  }
284
285
  public void edit_redo() {
286
    getActiveTextEditor().redo();
287
  }
288
289
  public void edit_cut() {
290
    getActiveTextEditor().cut();
291
  }
292
293
  public void edit_copy() {
294
    getActiveTextEditor().copy();
295
  }
296
297
  public void edit_paste() {
298
    getActiveTextEditor().paste();
299
  }
300
301
  public void edit_select_all() {
302
    getActiveTextEditor().selectAll();
303
  }
304
305
  public void edit_find() {
306
    final var nodes = getMainScene().getStatusBar().getLeftItems();
307
308
    if( nodes.isEmpty() ) {
309
      final var searchBar = new SearchBar();
310
311
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
312
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
313
314
      searchBar.setOnCancelAction( ( event ) -> {
315
        final var editor = getActiveTextEditor();
316
        nodes.remove( searchBar );
317
        editor.unstylize( STYLE_SEARCH );
318
        editor.getNode().requestFocus();
319
      } );
320
321
      searchBar.addInputListener( ( c, o, n ) -> {
322
        if( n != null && !n.isEmpty() ) {
323
          mSearchModel.search( n, getActiveTextEditor().getText() );
324
        }
325
      } );
326
327
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
328
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
329
330
      nodes.add( searchBar );
331
      searchBar.requestFocus();
332
    }
333
    else {
334
      nodes.clear();
335
    }
336
  }
337
338
  public void edit_find_next() {
339
    mSearchModel.advance();
340
  }
341
342
  public void edit_find_prev() {
343
    mSearchModel.retreat();
344
  }
345
346
  public void edit_preferences() {
347
    try {
348
      new PreferencesController( getWorkspace() ).show();
349
    } catch( final Exception ex ) {
350
      clue( ex );
351
    }
352
  }
353
354
  public void format_bold() {
355
    getActiveTextEditor().bold();
356
  }
357
358
  public void format_italic() {
359
    getActiveTextEditor().italic();
360
  }
361
362
  public void format_monospace() {
363
    getActiveTextEditor().monospace();
364
  }
365
366
  public void format_superscript() {
367
    getActiveTextEditor().superscript();
368
  }
369
370
  public void format_subscript() {
371
    getActiveTextEditor().subscript();
372
  }
373
374
  public void format_strikethrough() {
375
    getActiveTextEditor().strikethrough();
376
  }
377
378
  public void insert_blockquote() {
379
    getActiveTextEditor().blockquote();
380
  }
381
382
  public void insert_code() {
383
    getActiveTextEditor().code();
384
  }
385
386
  public void insert_fenced_code_block() {
387
    getActiveTextEditor().fencedCodeBlock();
388
  }
389
390
  public void insert_link() {
391
    insertObject( createLinkDialog() );
392
  }
393
394
  public void insert_image() {
395
    insertObject( createImageDialog() );
396
  }
397
398
  private void insertObject( final Dialog<String> dialog ) {
399
    final var textArea = getActiveTextEditor().getTextArea();
400
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
401
  }
402
403
  private Dialog<String> createLinkDialog() {
404
    return new LinkDialog( getWindow(), createHyperlinkModel() );
405
  }
406
407
  private Dialog<String> createImageDialog() {
408
    final var path = getActiveTextEditor().getPath();
409
    final var parentDir = path.getParent();
410
    return new ImageDialog( getWindow(), parentDir );
411
  }
412
413
  /**
414
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
415
   * the Markdown AST.
416
   *
417
   * @return An instance containing the link URL and display text.
418
   */
419
  private HyperlinkModel createHyperlinkModel() {
420
    final var context = getMainPane().createProcessorContext();
421
    final var editor = getActiveTextEditor();
422
    final var textArea = editor.getTextArea();
423
    final var selectedText = textArea.getSelectedText();
424
425
    // Convert current paragraph to Markdown nodes.
426
    final var mp = MarkdownProcessor.create( context );
427
    final var p = textArea.getCurrentParagraph();
428
    final var paragraph = textArea.getText( p );
429
    final var node = mp.toNode( paragraph );
430
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
431
    final var link = visitor.process( node );
432
433
    if( link != null ) {
434
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
435
    }
436
437
    return createHyperlinkModel( link, selectedText );
438
  }
439
440
  private HyperlinkModel createHyperlinkModel(
441
    final Link link, final String selection ) {
442
443
    return link == null
444
      ? new HyperlinkModel( selection, "https://localhost" )
445
      : new HyperlinkModel( link );
446
  }
447
448
  public void insert_heading_1() {
449
    insert_heading( 1 );
450
  }
451
452
  public void insert_heading_2() {
453
    insert_heading( 2 );
454
  }
455
456
  public void insert_heading_3() {
457
    insert_heading( 3 );
458
  }
459
460
  private void insert_heading( final int level ) {
461
    getActiveTextEditor().heading( level );
462
  }
463
464
  public void insert_unordered_list() {
465
    getActiveTextEditor().unorderedList();
466
  }
467
468
  public void insert_ordered_list() {
469
    getActiveTextEditor().orderedList();
470
  }
471
472
  public void insert_horizontal_rule() {
473
    getActiveTextEditor().horizontalRule();
474
  }
475
476
  public void definition_create() {
477
    getActiveTextDefinition().createDefinition();
478
  }
479
480
  public void definition_rename() {
481
    getActiveTextDefinition().renameDefinition();
482
  }
483
484
  public void definition_delete() {
485
    getActiveTextDefinition().deleteDefinitions();
486
  }
487
488
  public void definition_autoinsert() {
489
    getMainPane().autoinsert();
490
  }
491
492
  public void view_refresh() {
493
    getMainPane().viewRefresh();
494
  }
495
496
  public void view_preview() {
497
    getMainPane().viewPreview();
498
  }
499
500
  public void view_outline() {
501
    getMainPane().viewOutline();
502
  }
503
504
  public void view_files() {getMainPane().viewFiles();}
505
506
  public void view_statistics() {
507
    getMainPane().viewStatistics();
508
  }
509
510
  public void view_menubar() {
511
    getMainScene().toggleMenuBar();
512
  }
513
514
  public void view_toolbar() {
515
    getMainScene().toggleToolBar();
516
  }
517
518
  public void view_statusbar() {
519
    getMainScene().toggleStatusBar();
520
  }
521
522
  public void view_log() {
523
    mLogView.view();
524
  }
525
526
  public void help_about() {
527
    final var alert = new Alert( INFORMATION );
528
    final var prefix = "Dialog.about.";
529
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
530
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
531
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
532
    alert.setGraphic( ICON_DIALOG_NODE );
533
    alert.initOwner( getWindow() );
534
    alert.showAndWait();
535
  }
536
537
  /**
538
   * Concatenates all the files in the same directory as the given file into
539
   * a string. The extension is determined by the given file name pattern; the
540
   * order files are concatenated is based on their numeric sort order (this
541
   * avoids lexicographic sorting).
542
   * <p>
543
   * If the parent path to the file being edited in the text editor cannot
544
   * be found then this will return the editor's text, without iterating through
545
   * the parent directory. (Should never happen, but who knows?)
546
   * </p>
547
   * <p>
548
   * New lines are automatically appended to separate each file.
549
   * </p>
550
   *
551
   * @param editor The text editor containing
552
   * @return All files in the same directory as the file being edited
553
   * concatenated into a single string.
554
   */
555
  private String append( final TextEditor editor ) {
556
    final var pattern = editor.getPath();
557
    final var parent = pattern.getParent();
558
559
    // Short-circuit because nothing else can be done.
560
    if( parent == null ) {
561
      clue( "Main.status.export.concat.parent", pattern );
562
      return editor.getText();
563
    }
564
565
    final var filename = pattern.getFileName().toString();
566
    final var extension = getExtension( filename );
567
568
    if( extension.isBlank() ) {
569
      clue( "Main.status.export.concat.extension", filename );
570
      return editor.getText();
571
    }
572
573
    try {
574
      final var glob = "**/*." + extension;
575
      final ArrayList<Path> files = new ArrayList<>();
576
      walk( parent, glob, files::add );
577
      files.sort( new AlphanumComparator<>() );
578
579
      final var text = new StringBuilder( DOCUMENT_LENGTH );
580
581
      files.forEach( file -> {
582
        try {
583
          clue( "Main.status.export.concat", file );
584
          text.append( readString( file ) );
585
        } catch( final IOException ex ) {
586
          clue( "Main.status.export.concat.io", file );
587
        }
588
      } );
589
590
      return text.toString();
591
    } catch( final Throwable t ) {
592
      clue( t );
593
      return editor.getText();
594
    }
595
  }
596
597
  private Optional<List<File>> pickFiles( final Options... options ) {
598
    return createPicker( options ).choose();
599
  }
600
601
  private Optional<List<File>> pickFiles(
602
    final File filename, final Options... options ) {
603
    final var picker = createPicker( options );
604
    picker.setInitialFilename( filename );
605
    return picker.choose();
606
  }
607
608
  private FilePicker createPicker( final Options... options ) {
609
    final var factory = new FilePickerFactory( getWorkspace() );
610
    return factory.createModal( getWindow(), options );
49
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
50
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
51
import static com.keenwrite.util.FileWalker.walk;
52
import static java.nio.file.Files.readString;
53
import static java.nio.file.Files.writeString;
54
import static java.util.concurrent.Executors.newFixedThreadPool;
55
import static javafx.application.Platform.runLater;
56
import static javafx.event.Event.fireEvent;
57
import static javafx.scene.control.Alert.AlertType.INFORMATION;
58
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
59
import static org.apache.commons.io.FilenameUtils.getExtension;
60
61
/**
62
 * Responsible for abstracting how functionality is mapped to the application.
63
 * This allows users to customize accelerator keys and will provide pluggable
64
 * functionality so that different text markup languages can change documents
65
 * using their respective syntax.
66
 */
67
public final class GuiCommands {
68
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
69
70
  private static final String STYLE_SEARCH = "search";
71
72
  /**
73
   * Sci-fi genres, which are can be longer than other genres, typically fall
74
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
75
   * memory when concatenating files together when exporting novels.
76
   */
77
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
78
79
  /**
80
   * When an action is executed, this is one of the recipients.
81
   */
82
  private final MainPane mMainPane;
83
84
  private final MainScene mMainScene;
85
86
  private final LogView mLogView;
87
88
  /**
89
   * Tracks finding text in the active document.
90
   */
91
  private final SearchModel mSearchModel;
92
93
  public GuiCommands( final MainScene scene, final MainPane pane ) {
94
    mMainScene = scene;
95
    mMainPane = pane;
96
    mLogView = new LogView();
97
    mSearchModel = new SearchModel();
98
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
99
      final var editor = getActiveTextEditor();
100
101
      // Clear highlighted areas before highlighting a new region.
102
      if( o != null ) {
103
        editor.unstylize( STYLE_SEARCH );
104
      }
105
106
      if( n != null ) {
107
        editor.moveTo( n.getStart() );
108
        editor.stylize( n, STYLE_SEARCH );
109
      }
110
    } );
111
112
    // When the active text editor changes ...
113
    mMainPane.textEditorProperty().addListener(
114
      ( c, o, n ) -> {
115
        // ... update the haystack.
116
        mSearchModel.search( getActiveTextEditor().getText() );
117
118
        // ... update the status bar with the current caret position.
119
        if( n != null ) {
120
          CaretMovedEvent.fire( n.getCaret() );
121
        }
122
      }
123
    );
124
  }
125
126
  public void file_new() {
127
    getMainPane().newTextEditor();
128
  }
129
130
  public void file_open() {
131
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
132
  }
133
134
  public void file_close() {
135
    getMainPane().close();
136
  }
137
138
  public void file_close_all() {
139
    getMainPane().closeAll();
140
  }
141
142
  public void file_save() {
143
    getMainPane().save();
144
  }
145
146
  public void file_save_as() {
147
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
148
  }
149
150
  public void file_save_all() {
151
    getMainPane().saveAll();
152
  }
153
154
  /**
155
   * Converts the actively edited file in the given file format.
156
   *
157
   * @param format The destination file format.
158
   */
159
  private void file_export( final ExportFormat format ) {
160
    file_export( format, false );
161
  }
162
163
  /**
164
   * Converts one or more files into the given file format. If {@code dir}
165
   * is set to true, this will first append all files in the same directory
166
   * as the actively edited file.
167
   *
168
   * @param format The destination file format.
169
   * @param dir    Export all files in the actively edited file's directory.
170
   */
171
  private void file_export( final ExportFormat format, final boolean dir ) {
172
    final var main = getMainPane();
173
    final var editor = main.getTextEditor();
174
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
175
    final var filename = format.toExportFilename( editor.getPath() );
176
    final var selection = pickFile(
177
      Constants.PDF_DEFAULT.getName().equals( exported.get().getName() )
178
        ? filename
179
        : exported.get(), FILE_EXPORT
180
    );
181
182
    selection.ifPresent( ( files ) -> {
183
      editor.save();
184
185
      final var file = files.get( 0 );
186
      final var path = file.toPath();
187
      final var document = dir ? append( editor ) : editor.getText();
188
      final var context = main.createProcessorContext( path, format );
189
190
      final var task = new Task<Path>() {
191
        @Override
192
        protected Path call() throws Exception {
193
          final var chain = createProcessors( context );
194
          final var export = chain.apply( document );
195
196
          // Processors can export binary files. In such cases, processors
197
          // return null to prevent further processing.
198
          return export == null ? null : writeString( path, export );
199
        }
200
      };
201
202
      task.setOnSucceeded(
203
        e -> {
204
          // Remember the exported file name for next time.
205
          exported.setValue( file );
206
207
          final var result = task.getValue();
208
209
          // Binary formats must notify users of success independently.
210
          if( result != null ) {
211
            clue( "Main.status.export.success", result );
212
          }
213
        }
214
      );
215
216
      task.setOnFailed( e -> {
217
        final var ex = task.getException();
218
        clue( ex );
219
220
        if( ex instanceof TypeNotPresentException ) {
221
          fireExportFailedEvent();
222
        }
223
      } );
224
225
      sExecutor.execute( task );
226
    } );
227
  }
228
229
  /**
230
   * @param dir {@code true} means to export all files in the active file
231
   *            editor's directory; {@code false} means to export only the
232
   *            actively edited file.
233
   */
234
  private void file_export_pdf( final boolean dir ) {
235
    final var workspace = getWorkspace();
236
    final var themes = workspace.getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
237
    final var theme = workspace.stringProperty(
238
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
239
240
    if( Typesetter.canRun() ) {
241
      // If the typesetter is installed, allow the user to select a theme. If
242
      // the themes aren't installed, a status message will appear.
243
      if( ThemePicker.choose( themes, theme ) ) {
244
        file_export( APPLICATION_PDF, dir );
245
      }
246
    }
247
    else {
248
      fireExportFailedEvent();
249
    }
250
  }
251
252
  public void file_export_pdf() {
253
    file_export_pdf( false );
254
  }
255
256
  public void file_export_pdf_dir() {
257
    file_export_pdf( true );
258
  }
259
260
  public void file_export_html_svg() {
261
    file_export( HTML_TEX_SVG );
262
  }
263
264
  public void file_export_html_tex() {
265
    file_export( HTML_TEX_DELIMITED );
266
  }
267
268
  public void file_export_xhtml_tex() {
269
    file_export( XHTML_TEX );
270
  }
271
272
  private void fireExportFailedEvent() {
273
    runLater( ExportFailedEvent::fire );
274
  }
275
276
  public void file_exit() {
277
    final var window = getWindow();
278
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
279
  }
280
281
  public void edit_undo() {
282
    getActiveTextEditor().undo();
283
  }
284
285
  public void edit_redo() {
286
    getActiveTextEditor().redo();
287
  }
288
289
  public void edit_cut() {
290
    getActiveTextEditor().cut();
291
  }
292
293
  public void edit_copy() {
294
    getActiveTextEditor().copy();
295
  }
296
297
  public void edit_paste() {
298
    getActiveTextEditor().paste();
299
  }
300
301
  public void edit_select_all() {
302
    getActiveTextEditor().selectAll();
303
  }
304
305
  public void edit_find() {
306
    final var nodes = getMainScene().getStatusBar().getLeftItems();
307
308
    if( nodes.isEmpty() ) {
309
      final var searchBar = new SearchBar();
310
311
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
312
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
313
314
      searchBar.setOnCancelAction( ( event ) -> {
315
        final var editor = getActiveTextEditor();
316
        nodes.remove( searchBar );
317
        editor.unstylize( STYLE_SEARCH );
318
        editor.getNode().requestFocus();
319
      } );
320
321
      searchBar.addInputListener( ( c, o, n ) -> {
322
        if( n != null && !n.isEmpty() ) {
323
          mSearchModel.search( n, getActiveTextEditor().getText() );
324
        }
325
      } );
326
327
      searchBar.setOnNextAction( ( event ) -> edit_find_next() );
328
      searchBar.setOnPrevAction( ( event ) -> edit_find_prev() );
329
330
      nodes.add( searchBar );
331
      searchBar.requestFocus();
332
    }
333
    else {
334
      nodes.clear();
335
    }
336
  }
337
338
  public void edit_find_next() {
339
    mSearchModel.advance();
340
  }
341
342
  public void edit_find_prev() {
343
    mSearchModel.retreat();
344
  }
345
346
  public void edit_preferences() {
347
    try {
348
      new PreferencesController( getWorkspace() ).show();
349
    } catch( final Exception ex ) {
350
      clue( ex );
351
    }
352
  }
353
354
  public void format_bold() {
355
    getActiveTextEditor().bold();
356
  }
357
358
  public void format_italic() {
359
    getActiveTextEditor().italic();
360
  }
361
362
  public void format_monospace() {
363
    getActiveTextEditor().monospace();
364
  }
365
366
  public void format_superscript() {
367
    getActiveTextEditor().superscript();
368
  }
369
370
  public void format_subscript() {
371
    getActiveTextEditor().subscript();
372
  }
373
374
  public void format_strikethrough() {
375
    getActiveTextEditor().strikethrough();
376
  }
377
378
  public void insert_blockquote() {
379
    getActiveTextEditor().blockquote();
380
  }
381
382
  public void insert_code() {
383
    getActiveTextEditor().code();
384
  }
385
386
  public void insert_fenced_code_block() {
387
    getActiveTextEditor().fencedCodeBlock();
388
  }
389
390
  public void insert_link() {
391
    insertObject( createLinkDialog() );
392
  }
393
394
  public void insert_image() {
395
    insertObject( createImageDialog() );
396
  }
397
398
  private void insertObject( final Dialog<String> dialog ) {
399
    final var textArea = getActiveTextEditor().getTextArea();
400
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
401
  }
402
403
  private Dialog<String> createLinkDialog() {
404
    return new LinkDialog( getWindow(), createHyperlinkModel() );
405
  }
406
407
  private Dialog<String> createImageDialog() {
408
    final var path = getActiveTextEditor().getPath();
409
    final var parentDir = path.getParent();
410
    return new ImageDialog( getWindow(), parentDir );
411
  }
412
413
  /**
414
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
415
   * the Markdown AST.
416
   *
417
   * @return An instance containing the link URL and display text.
418
   */
419
  private HyperlinkModel createHyperlinkModel() {
420
    final var context = getMainPane().createProcessorContext();
421
    final var editor = getActiveTextEditor();
422
    final var textArea = editor.getTextArea();
423
    final var selectedText = textArea.getSelectedText();
424
425
    // Convert current paragraph to Markdown nodes.
426
    final var mp = MarkdownProcessor.create( context );
427
    final var p = textArea.getCurrentParagraph();
428
    final var paragraph = textArea.getText( p );
429
    final var node = mp.toNode( paragraph );
430
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
431
    final var link = visitor.process( node );
432
433
    if( link != null ) {
434
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
435
    }
436
437
    return createHyperlinkModel( link, selectedText );
438
  }
439
440
  private HyperlinkModel createHyperlinkModel(
441
    final Link link, final String selection ) {
442
443
    return link == null
444
      ? new HyperlinkModel( selection, "https://localhost" )
445
      : new HyperlinkModel( link );
446
  }
447
448
  public void insert_heading_1() {
449
    insert_heading( 1 );
450
  }
451
452
  public void insert_heading_2() {
453
    insert_heading( 2 );
454
  }
455
456
  public void insert_heading_3() {
457
    insert_heading( 3 );
458
  }
459
460
  private void insert_heading( final int level ) {
461
    getActiveTextEditor().heading( level );
462
  }
463
464
  public void insert_unordered_list() {
465
    getActiveTextEditor().unorderedList();
466
  }
467
468
  public void insert_ordered_list() {
469
    getActiveTextEditor().orderedList();
470
  }
471
472
  public void insert_horizontal_rule() {
473
    getActiveTextEditor().horizontalRule();
474
  }
475
476
  public void definition_create() {
477
    getActiveTextDefinition().createDefinition();
478
  }
479
480
  public void definition_rename() {
481
    getActiveTextDefinition().renameDefinition();
482
  }
483
484
  public void definition_delete() {
485
    getActiveTextDefinition().deleteDefinitions();
486
  }
487
488
  public void definition_autoinsert() {
489
    getMainPane().autoinsert();
490
  }
491
492
  public void view_refresh() {
493
    getMainPane().viewRefresh();
494
  }
495
496
  public void view_preview() {
497
    getMainPane().viewPreview();
498
  }
499
500
  public void view_outline() {
501
    getMainPane().viewOutline();
502
  }
503
504
  public void view_files() {getMainPane().viewFiles();}
505
506
  public void view_statistics() {
507
    getMainPane().viewStatistics();
508
  }
509
510
  public void view_menubar() {
511
    getMainScene().toggleMenuBar();
512
  }
513
514
  public void view_toolbar() {
515
    getMainScene().toggleToolBar();
516
  }
517
518
  public void view_statusbar() {
519
    getMainScene().toggleStatusBar();
520
  }
521
522
  public void view_log() {
523
    mLogView.view();
524
  }
525
526
  public void help_about() {
527
    final var alert = new Alert( INFORMATION );
528
    final var prefix = "Dialog.about.";
529
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
530
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
531
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
532
    alert.setGraphic( ICON_DIALOG_NODE );
533
    alert.initOwner( getWindow() );
534
    alert.showAndWait();
535
  }
536
537
  /**
538
   * Concatenates all the files in the same directory as the given file into
539
   * a string. The extension is determined by the given file name pattern; the
540
   * order files are concatenated is based on their numeric sort order (this
541
   * avoids lexicographic sorting).
542
   * <p>
543
   * If the parent path to the file being edited in the text editor cannot
544
   * be found then this will return the editor's text, without iterating through
545
   * the parent directory. (Should never happen, but who knows?)
546
   * </p>
547
   * <p>
548
   * New lines are automatically appended to separate each file.
549
   * </p>
550
   *
551
   * @param editor The text editor containing
552
   * @return All files in the same directory as the file being edited
553
   * concatenated into a single string.
554
   */
555
  private String append( final TextEditor editor ) {
556
    final var pattern = editor.getPath();
557
    final var parent = pattern.getParent();
558
559
    // Short-circuit because nothing else can be done.
560
    if( parent == null ) {
561
      clue( "Main.status.export.concat.parent", pattern );
562
      return editor.getText();
563
    }
564
565
    final var filename = pattern.getFileName().toString();
566
    final var extension = getExtension( filename );
567
568
    if( extension.isBlank() ) {
569
      clue( "Main.status.export.concat.extension", filename );
570
      return editor.getText();
571
    }
572
573
    try {
574
      final var glob = "**/*." + extension;
575
      final ArrayList<Path> files = new ArrayList<>();
576
      walk( parent, glob, files::add );
577
      files.sort( new AlphanumComparator<>() );
578
579
      final var text = new StringBuilder( DOCUMENT_LENGTH );
580
581
      files.forEach( file -> {
582
        try {
583
          clue( "Main.status.export.concat", file );
584
          text.append( readString( file ) );
585
        } catch( final IOException ex ) {
586
          clue( "Main.status.export.concat.io", file );
587
        }
588
      } );
589
590
      return text.toString();
591
    } catch( final Throwable t ) {
592
      clue( t );
593
      return editor.getText();
594
    }
595
  }
596
597
  private Optional<List<File>> pickFiles( final SelectionType type ) {
598
    return createPicker( type ).choose();
599
  }
600
601
  @SuppressWarnings( "SameParameterValue" )
602
  private Optional<List<File>> pickFile(
603
    final File filename, final SelectionType type ) {
604
    final var picker = createPicker( type );
605
    picker.setInitialFilename( filename );
606
    return picker.choose();
607
  }
608
609
  private FilePicker createPicker( final SelectionType type ) {
610
    final var factory = new FilePickerFactory( getWorkspace() );
611
    return factory.createModal( getWindow(), type );
611612
  }
612613
M src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
22
package com.keenwrite.ui.explorer;
33
4
import com.io7m.jwheatsheaf.ui.JWFileChoosers;
54
import com.keenwrite.Messages;
65
import com.keenwrite.preferences.Workspace;
76
import javafx.beans.property.ObjectProperty;
87
import javafx.scene.Node;
98
import javafx.stage.FileChooser;
109
import javafx.stage.Window;
1110
1211
import java.io.File;
1312
import java.nio.file.Path;
14
import java.util.ArrayList;
1513
import java.util.List;
1614
import java.util.Locale;
1715
import java.util.Optional;
1816
19
import static com.io7m.jwheatsheaf.api.JWFileChooserAction.*;
20
import static com.io7m.jwheatsheaf.api.JWFileChooserConfiguration.Builder;
21
import static com.io7m.jwheatsheaf.api.JWFileChooserConfiguration.builder;
22
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
23
import static com.keenwrite.events.StatusEvent.clue;
2417
import static com.keenwrite.preferences.AppKeys.KEY_UI_RECENT_DIR;
25
import static java.nio.file.FileSystems.getDefault;
26
import static java.util.Optional.ofNullable;
18
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
19
import static java.lang.String.format;
2720
2821
/**
2922
 * Shim for a {@link FilePicker} instance that is implemented in pure Java.
3023
 * This particular picker is added to avoid using the bug-ridden JavaFX
3124
 * {@link FileChooser} that invokes the native file chooser.
3225
 */
3326
public class FilePickerFactory {
34
  public enum Options {
35
    DIRECTORY_OPEN,
36
    FILE_IMPORT,
37
    FILE_EXPORT,
38
    FILE_OPEN_SINGLE,
39
    FILE_OPEN_MULTIPLE,
40
    FILE_OPEN_NEW,
41
    FILE_SAVE_AS,
42
    PERMIT_CREATE_DIRS,
27
  public enum SelectionType {
28
    DIRECTORY_OPEN( "open" ),
29
    FILE_IMPORT( "import" ),
30
    FILE_EXPORT( "export" ),
31
    FILE_OPEN_SINGLE( "open" ),
32
    FILE_OPEN_MULTIPLE( "open" ),
33
    FILE_OPEN_NEW( "open" ),
34
    FILE_SAVE_AS( "save" );
35
36
    private final String mTitle;
37
38
    SelectionType( final String title ) {
39
      assert title != null;
40
      mTitle = Messages.get( format( "Dialog.file.choose.%s.title", title ) );
41
    }
42
43
    public String getTitle() {
44
      return mTitle;
45
    }
4346
  }
4447
...
5255
5356
  public FilePicker createModal(
54
    final Window owner, final Options... options ) {
55
    final var picker = new PureFilePicker( owner, options );
57
    final Window owner, final SelectionType options ) {
58
    final var picker = new NativeFilePicker( owner, options );
59
5660
    picker.setInitialDirectory( mDirectory.get().toPath() );
5761
...
6468
6569
  /**
66
   * Pure Java implementation of a file selection widget.
70
   * Operating system's file selection dialog.
6771
   */
68
  private class PureFilePicker implements FilePicker {
69
    private final Window mParent;
70
    private final Builder mBuilder;
71
72
    private PureFilePicker( final Window window, final Options... options ) {
73
      mParent = window;
74
      mBuilder = builder().setFileSystem( getDefault() );
75
76
      final var args = ofNullable( options ).orElse( options );
77
78
      var title = "Dialog.file.choose.open.title";
79
      var action = OPEN_EXISTING_SINGLE;
72
  private static final class NativeFilePicker implements FilePicker {
73
    private final FileChooser mChooser = new FileChooser();
74
    private final Window mOwner;
75
    private final SelectionType mType;
8076
81
      // It is a programming error to provide options that save or export to
82
      // multiple files.
83
      for( final var arg : args ) {
84
        switch( arg ) {
85
          case FILE_EXPORT -> {
86
            title = "Dialog.file.choose.export.title";
87
            action = CREATE;
88
          }
89
          case FILE_SAVE_AS -> {
90
            title = "Dialog.file.choose.save.title";
91
            action = CREATE;
92
          }
93
          case FILE_OPEN_SINGLE -> action = OPEN_EXISTING_SINGLE;
94
          case FILE_OPEN_MULTIPLE -> action = OPEN_EXISTING_MULTIPLE;
95
          case PERMIT_CREATE_DIRS -> mBuilder.setAllowDirectoryCreation( true );
96
        }
97
      }
77
    public NativeFilePicker( final Window owner, final SelectionType type ) {
78
      assert owner != null;
79
      assert type != null;
9880
99
      mBuilder.setTitle( Messages.get( title ) );
100
      mBuilder.setAction( action );
81
      mOwner = owner;
82
      mType = type;
10183
    }
10284
10385
    @Override
10486
    public void setInitialFilename( final File file ) {
105
      mBuilder.setInitialFileName( file.getName() );
87
      assert file != null;
88
      mChooser.setInitialFileName( file.getName() );
10689
    }
10790
10891
    @Override
10992
    public void setInitialDirectory( final Path path ) {
110
      mBuilder.setInitialDirectory( path );
93
      assert path != null;
94
      mChooser.setInitialDirectory( path.toFile() );
11195
    }
112
113
//    private JWFileChooserFilterType createFileFilters() {
114
//      final var filters = new JWFilterGlobFactory();
115
//
116
//      return filters.create( "PDF Files" )
117
//                    .addRule( INCLUDE, "**/*.pdf" )
118
//                    .addRule( EXCLUDE_AND_HALT, "**/.*" )
119
//                    .build();
120
//    }
12196
12297
    @Override
12398
    public Optional<List<File>> choose() {
124
      final var config = mBuilder.build();
125
      try( final var chooserType = JWFileChoosers.create() ) {
126
        final var chooser = chooserType.create( mParent, config );
127
        final var paths = chooser.showAndWait();
128
        final var files = new ArrayList<File>( paths.size() );
129
        paths.forEach( path -> {
130
          final var file = path.toFile();
131
          files.add( file );
132
133
          // Set to the directory of the last file opened successfully.
134
          setRecentDirectory( file );
135
        } );
136
137
        return files.isEmpty() ? Optional.empty() : Optional.of( files );
138
      } catch( final Exception ex ) {
139
        clue( ex );
99
      if( mType == FILE_OPEN_MULTIPLE ) {
100
        return Optional.of( mChooser.showOpenMultipleDialog( mOwner ) );
140101
      }
141
142
      return Optional.empty();
143
    }
144
  }
145
146
  /**
147
   * Sets the value for the most recent directly selected. This will get the
148
   * parent location from the given file. If the parent is a readable directory
149
   * then this will update the most recent directory property.
150
   *
151
   * @param file A file contained in a directory.
152
   */
153
  private void setRecentDirectory( final File file ) {
154
    assert file != null;
155102
156
    final var parent = file.getParentFile();
157
    final var dir = parent == null ? USER_DIRECTORY : parent;
103
      final File file = mType == FILE_EXPORT || mType == FILE_SAVE_AS
104
        ? mChooser.showSaveDialog( mOwner )
105
        : mChooser.showOpenDialog( mOwner );
158106
159
    if( dir.isDirectory() && dir.canRead() ) {
160
      mDirectory.setValue( dir );
107
      return file == null ? Optional.empty() : Optional.of( List.of( file ) );
161108
    }
162109
  }
M src/main/resources/com/keenwrite/messages.properties
268268
Dialog.file.choose.save.title=Save File
269269
Dialog.file.choose.export.title=Export File
270
Dialog.file.choose.import.title=Import File
270271
271272
Dialog.file.choose.filter.title.source=Source Files