| Author | DaveJarvis <email> |
|---|---|
| Date | 2021-05-15 14:54:45 GMT-0700 |
| Commit | 60f5d1a3e5e65f535c586853301ba00080c5d2a8 |
| Parent | 7ccfdd5 |
| @Override | ||
| public void start( final Stage stage ) { | ||
| - // Must be instantiated after the UI is initialized (i.e., not in main). | ||
| + // Must be instantiated after the UI is initialized (i.e., not in main) | ||
| + // because it interacts with GUI properties. | ||
| mWorkspace = new Workspace(); | ||
| Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ), | ||
| Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ), | ||
| - fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ) | ||
| + fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ), | ||
| + Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ), | ||
| + Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ), | ||
| + booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) ) | ||
| ) | ||
| ), |
| entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ), | ||
| + entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty(true) ), | ||
| entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ), | ||
| entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ) | ||
| } | ||
| - public BooleanProperty booleanProperty(final Key key) { | ||
| + public BooleanProperty booleanProperty( final Key key ) { | ||
| assert key != null; | ||
| return valuesProperty( key ); | ||
| public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" ); | ||
| public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" ); | ||
| + public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" ); | ||
| //@formatter:on | ||
| import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY; | ||
| import static com.keenwrite.events.StatusEvent.clue; | ||
| -import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH; | ||
| -import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION; | ||
| -import static java.lang.ProcessBuilder.Redirect.DISCARD; | ||
| -import static java.lang.String.format; | ||
| -import static java.lang.System.currentTimeMillis; | ||
| -import static java.lang.System.getProperty; | ||
| -import static java.nio.file.Files.*; | ||
| -import static java.util.Arrays.asList; | ||
| -import static java.util.concurrent.TimeUnit.*; | ||
| -import static org.apache.commons.io.FilenameUtils.removeExtension; | ||
| - | ||
| -/** | ||
| - * Responsible for invoking an executable to typeset text. This will | ||
| - * construct suitable command-line arguments to invoke the typesetting engine. | ||
| - */ | ||
| -public class Typesetter { | ||
| - private static final SysFile TYPESETTER = new SysFile( "mtxrun" ); | ||
| - | ||
| - private final Workspace mWorkspace; | ||
| - | ||
| - /** | ||
| - * Creates a new {@link Typesetter} instance capable of configuring the | ||
| - * typesetter used to generate a typeset document. | ||
| - */ | ||
| - public Typesetter( final Workspace workspace ) { | ||
| - mWorkspace = workspace; | ||
| - } | ||
| - | ||
| - public static boolean canRun() { | ||
| - return TYPESETTER.canRun(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * This will typeset the document using a new process. The return value only | ||
| - * indicates whether the typesetter exists, not whether the typesetting was | ||
| - * successful. | ||
| - * | ||
| - * @param in The input document to typeset. | ||
| - * @param out Path to the finished typeset document. | ||
| - * @throws IOException If the process could not be started. | ||
| - * @throws InterruptedException If the process was killed. | ||
| - * @throws TypesetterNotFoundException When no typesetter is along the PATH. | ||
| - */ | ||
| - public void typeset( final Path in, final Path out ) | ||
| - throws IOException, InterruptedException, TypesetterNotFoundException { | ||
| - if( TYPESETTER.canRun() ) { | ||
| - clue( "Main.status.typeset.began", out ); | ||
| - final var task = new TypesetTask( in, out ); | ||
| - final var time = currentTimeMillis(); | ||
| - final var success = task.typeset(); | ||
| - | ||
| - clue( "Main.status.typeset.ended." + (success ? "success" : "failure"), | ||
| - out, since( time ) | ||
| - ); | ||
| - } | ||
| - else { | ||
| - throw new TypesetterNotFoundException( TYPESETTER.toString() ); | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Calculates the time that has elapsed from the current time to the | ||
| - * given moment in time. | ||
| - * | ||
| - * @param start The starting time, which should be before the current time. | ||
| - * @return A human-readable formatted time. | ||
| - * @see #asElapsed(long) | ||
| - */ | ||
| - private static String since( final long start ) { | ||
| - return asElapsed( currentTimeMillis() - start ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Converts an elapsed time to a human-readable format (hours, minutes, | ||
| - * seconds, and milliseconds). | ||
| - * | ||
| - * @param elapsed An elapsed time, in milliseconds. | ||
| - * @return Human-readable elapsed time. | ||
| - */ | ||
| - private static String asElapsed( final long elapsed ) { | ||
| - final var hours = MILLISECONDS.toHours( elapsed ); | ||
| - final var eHours = elapsed - HOURS.toMillis( hours ); | ||
| - final var minutes = MILLISECONDS.toMinutes( eHours ); | ||
| - final var eMinutes = eHours - MINUTES.toMillis( minutes ); | ||
| - final var seconds = MILLISECONDS.toSeconds( eMinutes ); | ||
| - final var eSeconds = eMinutes - SECONDS.toMillis( seconds ); | ||
| - final var milliseconds = MILLISECONDS.toMillis( eSeconds ); | ||
| - | ||
| - return format( "%02d:%02d:%02d.%03d", | ||
| - hours, minutes, seconds, milliseconds ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Launches a task to typeset a document. | ||
| - */ | ||
| - private class TypesetTask implements Callable<Boolean> { | ||
| - private final List<String> mArgs = new ArrayList<>(); | ||
| - private final Path mInput; | ||
| - private final Path mOutput; | ||
| - | ||
| - /** | ||
| - * Working directory must be set because ConTeXt cannot write the | ||
| - * result to an arbitrary location. | ||
| - */ | ||
| - private final Path mDirectory; | ||
| - | ||
| - private TypesetTask( final Path input, final Path output ) { | ||
| - assert input != null; | ||
| - assert output != null; | ||
| - | ||
| - final var parentDir = output.getParent(); | ||
| - mInput = input; | ||
| - mOutput = output; | ||
| - mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Initializes ConTeXt, which means creating the cache directory if it | ||
| - * doesn't already exist. The theme entry point must be named 'main.tex'. | ||
| - * | ||
| - * @return {@code true} if the cache directory exists. | ||
| - */ | ||
| - private boolean reinitialize() { | ||
| - final var filename = mOutput.getFileName(); | ||
| - final var themes = getThemesPath(); | ||
| - final var theme = getThemesSelection(); | ||
| - final var cacheExists = !isEmpty( getCacheDir().toPath() ); | ||
| - | ||
| - // Ensure invoking multiple times will load the correct arguments. | ||
| - mArgs.clear(); | ||
| - mArgs.add( TYPESETTER.getName() ); | ||
| - | ||
| - if( cacheExists ) { | ||
| - mArgs.add( "--autogenerate" ); | ||
| - mArgs.add( "--script" ); | ||
| - mArgs.add( "mtx-context" ); | ||
| - mArgs.add( "--batchmode" ); | ||
| - mArgs.add( "--nonstopmode" ); | ||
| - mArgs.add( "--purgeall" ); | ||
| - mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" ); | ||
| - mArgs.add( "--environment='main'" ); | ||
| - mArgs.add( "--result='" + filename + "'" ); | ||
| - mArgs.add( mInput.toString() ); | ||
| - | ||
| - final var sb = new StringBuilder( 128 ); | ||
| - mArgs.forEach( arg -> sb.append( arg ).append( " " ) ); | ||
| - clue( sb.toString() ); | ||
| - } | ||
| - else { | ||
| - mArgs.add( "--generate" ); | ||
| - } | ||
| - | ||
| - return cacheExists; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Setting {@code TEXMFCACHE} when run on a fresh system fails on first | ||
| - * run. If the cache directory doesn't exist, attempt to create it, then | ||
| - * call ConTeXt to generate the PDF. This is brittle because if the | ||
| - * directory is empty, or not populated with cached data, a false positive | ||
| - * will be returned, resulting in no PDF being created. | ||
| - * | ||
| - * @return {@code true} if the document was typeset successfully. | ||
| - * @throws IOException If the process could not be started. | ||
| - * @throws InterruptedException If the process was killed. | ||
| - */ | ||
| - private boolean typeset() throws IOException, InterruptedException { | ||
| - return reinitialize() ? call() : call() && reinitialize() && call(); | ||
| - } | ||
| - | ||
| - @Override | ||
| - public Boolean call() throws IOException, InterruptedException { | ||
| - final var stdout = new BoundedCache<String, String>( 150 ); | ||
| - final var builder = new ProcessBuilder( mArgs ); | ||
| - builder.directory( mDirectory.toFile() ); | ||
| - builder.environment().put( "TEXMFCACHE", getCacheDir().toString() ); | ||
| - | ||
| - // Without redirecting (or draining) stderr, the command may not | ||
| - // terminate successfully. | ||
| - builder.redirectError( DISCARD ); | ||
| - | ||
| - final var process = builder.start(); | ||
| - | ||
| - // Reading from stdout allows slurping page numbers while generating. | ||
| - final var listener = new PaginationListener( | ||
| - process.getInputStream(), stdout ); | ||
| - listener.start(); | ||
| - | ||
| - process.waitFor(); | ||
| - final var exit = process.exitValue(); | ||
| - process.destroy(); | ||
| - | ||
| - // If there was an error, the typesetter will leave behind log, pdf, and | ||
| - // error files. | ||
| - if( exit > 0 ) { | ||
| - final var xmlName = mInput.getFileName().toString(); | ||
| - final var srcName = mOutput.getFileName().toString(); | ||
| - final var logName = newExtension( xmlName, ".log" ); | ||
| - final var errName = newExtension( xmlName, "-error.log" ); | ||
| - final var pdfName = newExtension( xmlName, ".pdf" ); | ||
| - final var tuaName = newExtension( xmlName, ".tua" ); | ||
| - final var badName = newExtension( srcName, ".log" ); | ||
| - | ||
| - log( badName ); | ||
| - log( logName ); | ||
| - log( errName ); | ||
| - log( stdout.keySet().stream().toList() ); | ||
| - | ||
| - deleteIfExists( logName ); | ||
| - deleteIfExists( errName ); | ||
| - deleteIfExists( pdfName ); | ||
| - deleteIfExists( badName ); | ||
| - deleteIfExists( tuaName ); | ||
| - } | ||
| - | ||
| - // Exit value for a successful invocation of the typesetter. This value | ||
| - // value is returned when creating the cache on the first run as well as | ||
| - // creating PDFs on subsequent runs (after the cache has been created). | ||
| - // Users don't care about exit codes, only whether the PDF was generated. | ||
| - return exit == 0; | ||
| - } | ||
| - | ||
| - private Path newExtension( final String baseName, final String ext ) { | ||
| - return mOutput.resolveSibling( removeExtension( baseName ) + ext ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Fires a status message for each line in the given file. The file format | ||
| - * is somewhat machine-readable, but no effort beyond line splitting is | ||
| - * made to parse the text. | ||
| - * | ||
| - * @param path Path to the file containing error messages. | ||
| - */ | ||
| - private void log( final Path path ) throws IOException { | ||
| - if( exists( path ) ) { | ||
| - log( readAllLines( path ) ); | ||
| - } | ||
| - } | ||
| - | ||
| - private void log( final List<String> lines ) { | ||
| - final var splits = new ArrayList<String>( lines.size() * 2 ); | ||
| - | ||
| - for( final var line : lines ) { | ||
| - splits.addAll( asList( line.split( "\\\\n" ) ) ); | ||
| - } | ||
| - | ||
| - clue( splits ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the location of the cache directory. | ||
| - * | ||
| - * @return A fully qualified path to the location to store temporary | ||
| - * files between typesetting runs. | ||
| - */ | ||
| - private java.io.File getCacheDir() { | ||
| - final var temp = getProperty( "java.io.tmpdir" ); | ||
| - final var cache = Path.of( temp, "luatex-cache" ); | ||
| - return cache.toFile(); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Answers whether the given directory is empty. The typesetting software | ||
| - * creates a non-empty directory by default. The return value from this | ||
| - * method is a proxy to answering whether the typesetter has been run for | ||
| - * the first time or not. | ||
| - * | ||
| - * @param path The directory to check for emptiness. | ||
| - * @return {@code true} if the directory is empty. | ||
| - */ | ||
| - private boolean isEmpty( final Path path ) { | ||
| - try( final var stream = newDirectoryStream( path ) ) { | ||
| - return !stream.iterator().hasNext(); | ||
| - } catch( final NoSuchFileException | FileNotFoundException ex ) { | ||
| - // A missing directory means it doesn't exist, ergo is empty. | ||
| - return true; | ||
| - } catch( final IOException ex ) { | ||
| - throw new RuntimeException( ex ); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - /** | ||
| - * Responsible for parsing the output from the typesetting engine and | ||
| - * updating the status bar to provide assurance that typesetting is | ||
| - * executing. | ||
| - * | ||
| - * <p> | ||
| - * Example lines written to standard output: | ||
| - * </p> | ||
| - * <pre>{@code | ||
| - * pages > flushing realpage 15, userpage 15, subpage 15 | ||
| - * pages > flushing realpage 16, userpage 16, subpage 16 | ||
| - * pages > flushing realpage 1, userpage 1, subpage 1 | ||
| - * pages > flushing realpage 2, userpage 2, subpage 2 | ||
| - * }</pre> | ||
| - * <p> | ||
| - * The lines are parsed; the first number is displayed in a status bar | ||
| - * message. | ||
| - * </p> | ||
| - */ | ||
| - private static class PaginationListener extends Thread { | ||
| - private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" ); | ||
| - | ||
| - private final InputStream mInputStream; | ||
| - | ||
| - private final Map<String, String> mCache; | ||
| - | ||
| - public PaginationListener( | ||
| - final InputStream in, final Map<String, String> cache ) { | ||
| - mInputStream = in; | ||
| - mCache = cache; | ||
| - } | ||
| - | ||
| - @Override | ||
| - public void run() { | ||
| - try( final var reader = createReader() ) { | ||
| - int pageCount = 1; | ||
| - int passCount = 1; | ||
| - int pageTotal = 0; | ||
| - String line; | ||
| - | ||
| - while( (line = reader.readLine()) != null ) { | ||
| - mCache.put( line, "" ); | ||
| - | ||
| - if( line.startsWith( "pages" ) ) { | ||
| - // The bottleneck will be the typesetting engine writing to stdout, | ||
| - // not the parsing of stdout. | ||
| - final var scanner = new Scanner( line ).useDelimiter( DIGITS ); | ||
| - final var digits = scanner.next(); | ||
| - final var page = Integer.parseInt( digits ); | ||
| - | ||
| - // If the page number is less than the previous page count, it | ||
| - // means that the typesetting engine has started another pass. | ||
| - if( page < pageCount ) { | ||
| - passCount++; | ||
| - pageTotal = pageCount; | ||
| - } | ||
| - | ||
| - pageCount = page; | ||
| - | ||
| - // Let the user know that something is happening in the background. | ||
| - clue( "Main.status.typeset.page", | ||
| - pageCount, pageTotal < 1 ? "?" : pageTotal, passCount | ||
| - ); | ||
| - } | ||
| - } | ||
| - } catch( final IOException ex ) { | ||
| - throw new RuntimeException( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - private BufferedReader createReader() { | ||
| - return new BufferedReader( new InputStreamReader( mInputStream ) ); | ||
| - } | ||
| - } | ||
| - | ||
| - private File getThemesPath() { | ||
| - return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | ||
| - } | ||
| - | ||
| - private String getThemesSelection() { | ||
| - return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | ||
| +import static com.keenwrite.preferences.WorkspaceKeys.*; | ||
| +import static java.lang.ProcessBuilder.Redirect.DISCARD; | ||
| +import static java.lang.String.format; | ||
| +import static java.lang.System.currentTimeMillis; | ||
| +import static java.lang.System.getProperty; | ||
| +import static java.nio.file.Files.*; | ||
| +import static java.util.Arrays.asList; | ||
| +import static java.util.concurrent.TimeUnit.*; | ||
| +import static org.apache.commons.io.FilenameUtils.removeExtension; | ||
| + | ||
| +/** | ||
| + * Responsible for invoking an executable to typeset text. This will | ||
| + * construct suitable command-line arguments to invoke the typesetting engine. | ||
| + */ | ||
| +public class Typesetter { | ||
| + private static final SysFile TYPESETTER = new SysFile( "mtxrun" ); | ||
| + | ||
| + private final Workspace mWorkspace; | ||
| + | ||
| + /** | ||
| + * Creates a new {@link Typesetter} instance capable of configuring the | ||
| + * typesetter used to generate a typeset document. | ||
| + */ | ||
| + public Typesetter( final Workspace workspace ) { | ||
| + mWorkspace = workspace; | ||
| + } | ||
| + | ||
| + public static boolean canRun() { | ||
| + return TYPESETTER.canRun(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * This will typeset the document using a new process. The return value only | ||
| + * indicates whether the typesetter exists, not whether the typesetting was | ||
| + * successful. | ||
| + * | ||
| + * @param in The input document to typeset. | ||
| + * @param out Path to the finished typeset document. | ||
| + * @throws IOException If the process could not be started. | ||
| + * @throws InterruptedException If the process was killed. | ||
| + * @throws TypesetterNotFoundException When no typesetter is along the PATH. | ||
| + */ | ||
| + public void typeset( final Path in, final Path out ) | ||
| + throws IOException, InterruptedException, TypesetterNotFoundException { | ||
| + if( TYPESETTER.canRun() ) { | ||
| + clue( "Main.status.typeset.began", out ); | ||
| + final var task = new TypesetTask( in, out ); | ||
| + final var time = currentTimeMillis(); | ||
| + final var success = task.typeset(); | ||
| + | ||
| + clue( "Main.status.typeset.ended." + (success ? "success" : "failure"), | ||
| + out, since( time ) | ||
| + ); | ||
| + } | ||
| + else { | ||
| + throw new TypesetterNotFoundException( TYPESETTER.toString() ); | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Calculates the time that has elapsed from the current time to the | ||
| + * given moment in time. | ||
| + * | ||
| + * @param start The starting time, which should be before the current time. | ||
| + * @return A human-readable formatted time. | ||
| + * @see #asElapsed(long) | ||
| + */ | ||
| + private static String since( final long start ) { | ||
| + return asElapsed( currentTimeMillis() - start ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Converts an elapsed time to a human-readable format (hours, minutes, | ||
| + * seconds, and milliseconds). | ||
| + * | ||
| + * @param elapsed An elapsed time, in milliseconds. | ||
| + * @return Human-readable elapsed time. | ||
| + */ | ||
| + private static String asElapsed( final long elapsed ) { | ||
| + final var hours = MILLISECONDS.toHours( elapsed ); | ||
| + final var eHours = elapsed - HOURS.toMillis( hours ); | ||
| + final var minutes = MILLISECONDS.toMinutes( eHours ); | ||
| + final var eMinutes = eHours - MINUTES.toMillis( minutes ); | ||
| + final var seconds = MILLISECONDS.toSeconds( eMinutes ); | ||
| + final var eSeconds = eMinutes - SECONDS.toMillis( seconds ); | ||
| + final var milliseconds = MILLISECONDS.toMillis( eSeconds ); | ||
| + | ||
| + return format( "%02d:%02d:%02d.%03d", | ||
| + hours, minutes, seconds, milliseconds ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Launches a task to typeset a document. | ||
| + */ | ||
| + private class TypesetTask implements Callable<Boolean> { | ||
| + private final List<String> mArgs = new ArrayList<>(); | ||
| + private final Path mInput; | ||
| + private final Path mOutput; | ||
| + | ||
| + /** | ||
| + * Working directory must be set because ConTeXt cannot write the | ||
| + * result to an arbitrary location. | ||
| + */ | ||
| + private final Path mDirectory; | ||
| + | ||
| + private TypesetTask( final Path input, final Path output ) { | ||
| + assert input != null; | ||
| + assert output != null; | ||
| + | ||
| + final var parentDir = output.getParent(); | ||
| + mInput = input; | ||
| + mOutput = output; | ||
| + mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Initializes ConTeXt, which means creating the cache directory if it | ||
| + * doesn't already exist. The theme entry point must be named 'main.tex'. | ||
| + * | ||
| + * @return {@code true} if the cache directory exists. | ||
| + */ | ||
| + private boolean reinitialize() { | ||
| + final var filename = mOutput.getFileName(); | ||
| + final var themes = getThemesPath(); | ||
| + final var theme = getThemesSelection(); | ||
| + final var cacheExists = !isEmpty( getCacheDir().toPath() ); | ||
| + | ||
| + // Ensure invoking multiple times will load the correct arguments. | ||
| + mArgs.clear(); | ||
| + mArgs.add( TYPESETTER.getName() ); | ||
| + | ||
| + if( cacheExists ) { | ||
| + mArgs.add( "--autogenerate" ); | ||
| + mArgs.add( "--script" ); | ||
| + mArgs.add( "mtx-context" ); | ||
| + mArgs.add( "--batchmode" ); | ||
| + mArgs.add( "--nonstopmode" ); | ||
| + mArgs.add( "--purgeall" ); | ||
| + mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" ); | ||
| + mArgs.add( "--environment='main'" ); | ||
| + mArgs.add( "--result='" + filename + "'" ); | ||
| + mArgs.add( mInput.toString() ); | ||
| + | ||
| + final var sb = new StringBuilder( 128 ); | ||
| + mArgs.forEach( arg -> sb.append( arg ).append( " " ) ); | ||
| + clue( sb.toString() ); | ||
| + } | ||
| + else { | ||
| + mArgs.add( "--generate" ); | ||
| + } | ||
| + | ||
| + return cacheExists; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Setting {@code TEXMFCACHE} when run on a fresh system fails on first | ||
| + * run. If the cache directory doesn't exist, attempt to create it, then | ||
| + * call ConTeXt to generate the PDF. This is brittle because if the | ||
| + * directory is empty, or not populated with cached data, a false positive | ||
| + * will be returned, resulting in no PDF being created. | ||
| + * | ||
| + * @return {@code true} if the document was typeset successfully. | ||
| + * @throws IOException If the process could not be started. | ||
| + * @throws InterruptedException If the process was killed. | ||
| + */ | ||
| + private boolean typeset() throws IOException, InterruptedException { | ||
| + return reinitialize() ? call() : call() && reinitialize() && call(); | ||
| + } | ||
| + | ||
| + @Override | ||
| + public Boolean call() throws IOException, InterruptedException { | ||
| + final var stdout = new BoundedCache<String, String>( 150 ); | ||
| + final var builder = new ProcessBuilder( mArgs ); | ||
| + builder.directory( mDirectory.toFile() ); | ||
| + builder.environment().put( "TEXMFCACHE", getCacheDir().toString() ); | ||
| + | ||
| + // Without redirecting (or draining) stderr, the command may not | ||
| + // terminate successfully. | ||
| + builder.redirectError( DISCARD ); | ||
| + | ||
| + final var process = builder.start(); | ||
| + | ||
| + // Reading from stdout allows slurping page numbers while generating. | ||
| + final var listener = new PaginationListener( | ||
| + process.getInputStream(), stdout ); | ||
| + listener.start(); | ||
| + | ||
| + process.waitFor(); | ||
| + final var exit = process.exitValue(); | ||
| + process.destroy(); | ||
| + | ||
| + // If there was an error, the typesetter will leave behind log, pdf, and | ||
| + // error files. | ||
| + if( exit > 0 ) { | ||
| + final var xmlName = mInput.getFileName().toString(); | ||
| + final var srcName = mOutput.getFileName().toString(); | ||
| + final var logName = newExtension( xmlName, ".log" ); | ||
| + final var errName = newExtension( xmlName, "-error.log" ); | ||
| + final var pdfName = newExtension( xmlName, ".pdf" ); | ||
| + final var tuaName = newExtension( xmlName, ".tua" ); | ||
| + final var badName = newExtension( srcName, ".log" ); | ||
| + | ||
| + log( badName ); | ||
| + log( logName ); | ||
| + log( errName ); | ||
| + log( stdout.keySet().stream().toList() ); | ||
| + | ||
| + // Users may opt to keep these files around for debugging purposes. | ||
| + if( autoclean() ) { | ||
| + deleteIfExists( logName ); | ||
| + deleteIfExists( errName ); | ||
| + deleteIfExists( pdfName ); | ||
| + deleteIfExists( badName ); | ||
| + deleteIfExists( tuaName ); | ||
| + } | ||
| + } | ||
| + | ||
| + // Exit value for a successful invocation of the typesetter. This value | ||
| + // value is returned when creating the cache on the first run as well as | ||
| + // creating PDFs on subsequent runs (after the cache has been created). | ||
| + // Users don't care about exit codes, only whether the PDF was generated. | ||
| + return exit == 0; | ||
| + } | ||
| + | ||
| + private Path newExtension( final String baseName, final String ext ) { | ||
| + return mOutput.resolveSibling( removeExtension( baseName ) + ext ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Fires a status message for each line in the given file. The file format | ||
| + * is somewhat machine-readable, but no effort beyond line splitting is | ||
| + * made to parse the text. | ||
| + * | ||
| + * @param path Path to the file containing error messages. | ||
| + */ | ||
| + private void log( final Path path ) throws IOException { | ||
| + if( exists( path ) ) { | ||
| + log( readAllLines( path ) ); | ||
| + } | ||
| + } | ||
| + | ||
| + private void log( final List<String> lines ) { | ||
| + final var splits = new ArrayList<String>( lines.size() * 2 ); | ||
| + | ||
| + for( final var line : lines ) { | ||
| + splits.addAll( asList( line.split( "\\\\n" ) ) ); | ||
| + } | ||
| + | ||
| + clue( splits ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the location of the cache directory. | ||
| + * | ||
| + * @return A fully qualified path to the location to store temporary | ||
| + * files between typesetting runs. | ||
| + */ | ||
| + private java.io.File getCacheDir() { | ||
| + final var temp = getProperty( "java.io.tmpdir" ); | ||
| + final var cache = Path.of( temp, "luatex-cache" ); | ||
| + return cache.toFile(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers whether the given directory is empty. The typesetting software | ||
| + * creates a non-empty directory by default. The return value from this | ||
| + * method is a proxy to answering whether the typesetter has been run for | ||
| + * the first time or not. | ||
| + * | ||
| + * @param path The directory to check for emptiness. | ||
| + * @return {@code true} if the directory is empty. | ||
| + */ | ||
| + private boolean isEmpty( final Path path ) { | ||
| + try( final var stream = newDirectoryStream( path ) ) { | ||
| + return !stream.iterator().hasNext(); | ||
| + } catch( final NoSuchFileException | FileNotFoundException ex ) { | ||
| + // A missing directory means it doesn't exist, ergo is empty. | ||
| + return true; | ||
| + } catch( final IOException ex ) { | ||
| + throw new RuntimeException( ex ); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + /** | ||
| + * Responsible for parsing the output from the typesetting engine and | ||
| + * updating the status bar to provide assurance that typesetting is | ||
| + * executing. | ||
| + * | ||
| + * <p> | ||
| + * Example lines written to standard output: | ||
| + * </p> | ||
| + * <pre>{@code | ||
| + * pages > flushing realpage 15, userpage 15, subpage 15 | ||
| + * pages > flushing realpage 16, userpage 16, subpage 16 | ||
| + * pages > flushing realpage 1, userpage 1, subpage 1 | ||
| + * pages > flushing realpage 2, userpage 2, subpage 2 | ||
| + * }</pre> | ||
| + * <p> | ||
| + * The lines are parsed; the first number is displayed in a status bar | ||
| + * message. | ||
| + * </p> | ||
| + */ | ||
| + private static class PaginationListener extends Thread { | ||
| + private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" ); | ||
| + | ||
| + private final InputStream mInputStream; | ||
| + | ||
| + private final Map<String, String> mCache; | ||
| + | ||
| + public PaginationListener( | ||
| + final InputStream in, final Map<String, String> cache ) { | ||
| + mInputStream = in; | ||
| + mCache = cache; | ||
| + } | ||
| + | ||
| + @Override | ||
| + public void run() { | ||
| + try( final var reader = createReader() ) { | ||
| + int pageCount = 1; | ||
| + int passCount = 1; | ||
| + int pageTotal = 0; | ||
| + String line; | ||
| + | ||
| + while( (line = reader.readLine()) != null ) { | ||
| + mCache.put( line, "" ); | ||
| + | ||
| + if( line.startsWith( "pages" ) ) { | ||
| + // The bottleneck will be the typesetting engine writing to stdout, | ||
| + // not the parsing of stdout. | ||
| + final var scanner = new Scanner( line ).useDelimiter( DIGITS ); | ||
| + final var digits = scanner.next(); | ||
| + final var page = Integer.parseInt( digits ); | ||
| + | ||
| + // If the page number is less than the previous page count, it | ||
| + // means that the typesetting engine has started another pass. | ||
| + if( page < pageCount ) { | ||
| + passCount++; | ||
| + pageTotal = pageCount; | ||
| + } | ||
| + | ||
| + pageCount = page; | ||
| + | ||
| + // Let the user know that something is happening in the background. | ||
| + clue( "Main.status.typeset.page", | ||
| + pageCount, pageTotal < 1 ? "?" : pageTotal, passCount | ||
| + ); | ||
| + } | ||
| + } | ||
| + } catch( final IOException ex ) { | ||
| + throw new RuntimeException( ex ); | ||
| + } | ||
| + } | ||
| + | ||
| + private BufferedReader createReader() { | ||
| + return new BufferedReader( new InputStreamReader( mInputStream ) ); | ||
| + } | ||
| + } | ||
| + | ||
| + private File getThemesPath() { | ||
| + return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | ||
| + } | ||
| + | ||
| + private String getThemesSelection() { | ||
| + return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Answers whether logs and other files should be deleted upon error. The | ||
| + * log files are useful for debugging. | ||
| + * | ||
| + * @return {@code true} to delete generated files. | ||
| + */ | ||
| + private boolean autoclean() { | ||
| + return mWorkspace.toBoolean( KEY_TYPESET_CONTEXT_CLEAN ); | ||
| } | ||
| } |
| import com.keenwrite.ui.explorer.FilePickerFactory; | ||
| import com.keenwrite.ui.logging.LogView; | ||
| -import com.vladsch.flexmark.ast.Link; | ||
| -import javafx.concurrent.Task; | ||
| -import javafx.scene.control.Alert; | ||
| -import javafx.scene.control.Dialog; | ||
| -import javafx.stage.Window; | ||
| -import javafx.stage.WindowEvent; | ||
| - | ||
| -import java.io.File; | ||
| -import java.nio.file.Path; | ||
| -import java.util.List; | ||
| -import java.util.Optional; | ||
| -import java.util.concurrent.ExecutorService; | ||
| - | ||
| -import static com.keenwrite.Bootstrap.*; | ||
| -import static com.keenwrite.ExportFormat.*; | ||
| -import static com.keenwrite.Messages.get; | ||
| -import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | ||
| -import static com.keenwrite.events.StatusEvent.clue; | ||
| -import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH; | ||
| -import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION; | ||
| -import static com.keenwrite.processors.ProcessorFactory.createProcessors; | ||
| -import static com.keenwrite.ui.explorer.FilePickerFactory.Options; | ||
| -import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*; | ||
| -import static java.nio.file.Files.writeString; | ||
| -import static java.util.concurrent.Executors.newFixedThreadPool; | ||
| -import static javafx.application.Platform.runLater; | ||
| -import static javafx.event.Event.fireEvent; | ||
| -import static javafx.scene.control.Alert.AlertType.INFORMATION; | ||
| -import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | ||
| - | ||
| -/** | ||
| - * Responsible for abstracting how functionality is mapped to the application. | ||
| - * This allows users to customize accelerator keys and will provide pluggable | ||
| - * functionality so that different text markup languages can change documents | ||
| - * using their respective syntax. | ||
| - */ | ||
| -@SuppressWarnings( "NonAsciiCharacters" ) | ||
| -public final class ApplicationActions { | ||
| - private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | ||
| - | ||
| - private static final String STYLE_SEARCH = "search"; | ||
| - | ||
| - /** | ||
| - * When an action is executed, this is one of the recipients. | ||
| - */ | ||
| - private final MainPane mMainPane; | ||
| - | ||
| - private final MainScene mMainScene; | ||
| - | ||
| - private final LogView mLogView; | ||
| - | ||
| - /** | ||
| - * Tracks finding text in the active document. | ||
| - */ | ||
| - private final SearchModel mSearchModel; | ||
| - | ||
| - public ApplicationActions( final MainScene scene, final MainPane pane ) { | ||
| - mMainScene = scene; | ||
| - mMainPane = pane; | ||
| - mLogView = new LogView(); | ||
| - mSearchModel = new SearchModel(); | ||
| - mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | ||
| - final var editor = getActiveTextEditor(); | ||
| - | ||
| - // Clear highlighted areas before highlighting a new region. | ||
| - if( o != null ) { | ||
| - editor.unstylize( STYLE_SEARCH ); | ||
| - } | ||
| - | ||
| - if( n != null ) { | ||
| - editor.moveTo( n.getStart() ); | ||
| - editor.stylize( n, STYLE_SEARCH ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - // When the active text editor changes, update the haystack. | ||
| - mMainPane.activeTextEditorProperty().addListener( | ||
| - ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | ||
| - ); | ||
| - } | ||
| - | ||
| - public void file‿new() { | ||
| - getMainPane().newTextEditor(); | ||
| - } | ||
| - | ||
| - public void file‿open() { | ||
| - pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | ||
| - } | ||
| - | ||
| - public void file‿close() { | ||
| - getMainPane().close(); | ||
| - } | ||
| - | ||
| - public void file‿close_all() { | ||
| - getMainPane().closeAll(); | ||
| - } | ||
| - | ||
| - public void file‿save() { | ||
| - getMainPane().save(); | ||
| - } | ||
| - | ||
| - public void file‿save_as() { | ||
| - pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | ||
| - } | ||
| - | ||
| - public void file‿save_all() { | ||
| - getMainPane().saveAll(); | ||
| - } | ||
| - | ||
| - private void file‿export( final ExportFormat format ) { | ||
| - final var main = getMainPane(); | ||
| - final var editor = main.getActiveTextEditor(); | ||
| - final var filename = format.toExportFilename( editor.getPath() ); | ||
| - final var selection = pickFiles( filename, FILE_EXPORT ); | ||
| - | ||
| - selection.ifPresent( ( files ) -> { | ||
| - final var file = files.get( 0 ); | ||
| - final var path = file.toPath(); | ||
| - final var document = editor.getText(); | ||
| - final var context = main.createProcessorContext( path, format ); | ||
| - | ||
| - final var task = new Task<Path>() { | ||
| - @Override | ||
| - protected Path call() throws Exception { | ||
| - final var chain = createProcessors( context ); | ||
| - final var export = chain.apply( document ); | ||
| - | ||
| - // Processors can export binary files. In such cases, processors | ||
| - // return null to prevent further processing. | ||
| - return export == null ? null : writeString( path, export ); | ||
| - } | ||
| - }; | ||
| - | ||
| - task.setOnSucceeded( | ||
| - e -> { | ||
| - final var result = task.getValue(); | ||
| - | ||
| - // Binary formats must notify users of success independently. | ||
| - if( result != null ) { | ||
| - clue( "Main.status.export.success", result ); | ||
| - } | ||
| - } | ||
| - ); | ||
| - | ||
| - task.setOnFailed( e -> { | ||
| - final var ex = task.getException(); | ||
| - clue( ex ); | ||
| - | ||
| - if( ex instanceof TypeNotPresentException ) { | ||
| - fireExportFailedEvent(); | ||
| - } | ||
| - } ); | ||
| - | ||
| - sExecutor.execute( task ); | ||
| - } ); | ||
| - } | ||
| - | ||
| - public void file‿export‿pdf() { | ||
| - final var workspace = getWorkspace(); | ||
| - final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | ||
| - final var theme = workspace.stringProperty( | ||
| - KEY_TYPESET_CONTEXT_THEME_SELECTION ); | ||
| - | ||
| - if( Typesetter.canRun() ) { | ||
| - // If the typesetter is installed, allow the user to select a theme. If | ||
| - // the themes aren't installed, a status message will appear. | ||
| - if( ThemePicker.choose( themes, theme ) ) { | ||
| - file‿export( APPLICATION_PDF ); | ||
| - } | ||
| - } | ||
| - else { | ||
| - fireExportFailedEvent(); | ||
| - } | ||
| - } | ||
| - | ||
| - public void file‿export‿html_svg() { | ||
| - file‿export( HTML_TEX_SVG ); | ||
| - } | ||
| - | ||
| - public void file‿export‿html_tex() { | ||
| - file‿export( HTML_TEX_DELIMITED ); | ||
| - } | ||
| - | ||
| - public void file‿export‿xhtml_tex() { | ||
| - file‿export( XHTML_TEX ); | ||
| - } | ||
| - | ||
| - public void file‿export‿markdown() { | ||
| - file‿export( MARKDOWN_PLAIN ); | ||
| - } | ||
| - | ||
| - private void fireExportFailedEvent() { | ||
| - runLater( ExportFailedEvent::fireExportFailedEvent ); | ||
| - } | ||
| - | ||
| - public void file‿exit() { | ||
| - final var window = getWindow(); | ||
| - fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | ||
| - } | ||
| - | ||
| - public void edit‿undo() { | ||
| - getActiveTextEditor().undo(); | ||
| - } | ||
| - | ||
| - public void edit‿redo() { | ||
| - getActiveTextEditor().redo(); | ||
| - } | ||
| - | ||
| - public void edit‿cut() { | ||
| - getActiveTextEditor().cut(); | ||
| - } | ||
| - | ||
| - public void edit‿copy() { | ||
| - getActiveTextEditor().copy(); | ||
| - } | ||
| - | ||
| - public void edit‿paste() { | ||
| - getActiveTextEditor().paste(); | ||
| - } | ||
| - | ||
| - public void edit‿select_all() { | ||
| - getActiveTextEditor().selectAll(); | ||
| - } | ||
| - | ||
| - public void edit‿find() { | ||
| - final var nodes = getMainScene().getStatusBar().getLeftItems(); | ||
| - | ||
| - if( nodes.isEmpty() ) { | ||
| - final var searchBar = new SearchBar(); | ||
| - | ||
| - searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | ||
| - searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | ||
| - | ||
| - searchBar.setOnCancelAction( ( event ) -> { | ||
| - final var editor = getActiveTextEditor(); | ||
| - nodes.remove( searchBar ); | ||
| - editor.unstylize( STYLE_SEARCH ); | ||
| - editor.getNode().requestFocus(); | ||
| - } ); | ||
| - | ||
| - searchBar.addInputListener( ( c, o, n ) -> { | ||
| - if( n != null && !n.isEmpty() ) { | ||
| - mSearchModel.search( n, getActiveTextEditor().getText() ); | ||
| - } | ||
| - } ); | ||
| - | ||
| - searchBar.setOnNextAction( ( event ) -> edit‿find_next() ); | ||
| - searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() ); | ||
| - | ||
| - nodes.add( searchBar ); | ||
| - searchBar.requestFocus(); | ||
| - } | ||
| - else { | ||
| - nodes.clear(); | ||
| - } | ||
| - } | ||
| - | ||
| - public void edit‿find_next() { | ||
| - mSearchModel.advance(); | ||
| - } | ||
| - | ||
| - public void edit‿find_prev() { | ||
| - mSearchModel.retreat(); | ||
| - } | ||
| - | ||
| - public void edit‿preferences() { | ||
| - try { | ||
| - new PreferencesController( getWorkspace() ).show(); | ||
| - } catch( final Exception ex ) { | ||
| - clue( ex ); | ||
| - } | ||
| - } | ||
| - | ||
| - public void format‿bold() { | ||
| - getActiveTextEditor().bold(); | ||
| - } | ||
| - | ||
| - public void format‿italic() { | ||
| - getActiveTextEditor().italic(); | ||
| - } | ||
| - | ||
| - public void format‿superscript() { | ||
| - getActiveTextEditor().superscript(); | ||
| - } | ||
| - | ||
| - public void format‿subscript() { | ||
| - getActiveTextEditor().subscript(); | ||
| - } | ||
| - | ||
| - public void format‿strikethrough() { | ||
| - getActiveTextEditor().strikethrough(); | ||
| - } | ||
| - | ||
| - public void insert‿blockquote() { | ||
| - getActiveTextEditor().blockquote(); | ||
| - } | ||
| - | ||
| - public void insert‿code() { | ||
| - getActiveTextEditor().code(); | ||
| - } | ||
| - | ||
| - public void insert‿fenced_code_block() { | ||
| - getActiveTextEditor().fencedCodeBlock(); | ||
| - } | ||
| - | ||
| - public void insert‿link() { | ||
| - insertObject( createLinkDialog() ); | ||
| - } | ||
| - | ||
| - public void insert‿image() { | ||
| - insertObject( createImageDialog() ); | ||
| - } | ||
| - | ||
| - private void insertObject( final Dialog<String> dialog ) { | ||
| - final var textArea = getActiveTextEditor().getTextArea(); | ||
| - dialog.showAndWait().ifPresent( textArea::replaceSelection ); | ||
| - } | ||
| - | ||
| - private Dialog<String> createLinkDialog() { | ||
| - return new LinkDialog( getWindow(), createHyperlinkModel() ); | ||
| - } | ||
| - | ||
| - private Dialog<String> createImageDialog() { | ||
| - final var path = getActiveTextEditor().getPath(); | ||
| - final var parentDir = path.getParent(); | ||
| - return new ImageDialog( getWindow(), parentDir ); | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns one of: selected text, word under cursor, or parsed hyperlink from | ||
| - * the Markdown AST. | ||
| - * | ||
| - * @return An instance containing the link URL and display text. | ||
| - */ | ||
| - private HyperlinkModel createHyperlinkModel() { | ||
| - final var context = getMainPane().createProcessorContext(); | ||
| - final var editor = getActiveTextEditor(); | ||
| - final var textArea = editor.getTextArea(); | ||
| - final var selectedText = textArea.getSelectedText(); | ||
| - | ||
| - // Convert current paragraph to Markdown nodes. | ||
| - final var mp = MarkdownProcessor.create( context ); | ||
| - final var p = textArea.getCurrentParagraph(); | ||
| - final var paragraph = textArea.getText( p ); | ||
| - final var node = mp.toNode( paragraph ); | ||
| - final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | ||
| - final var link = visitor.process( node ); | ||
| - | ||
| - if( link != null ) { | ||
| - textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | ||
| - } | ||
| - | ||
| - return createHyperlinkModel( link, selectedText ); | ||
| - } | ||
| - | ||
| - private HyperlinkModel createHyperlinkModel( | ||
| - final Link link, final String selection ) { | ||
| - | ||
| - return link == null | ||
| - ? new HyperlinkModel( selection, "https://localhost" ) | ||
| - : new HyperlinkModel( link ); | ||
| - } | ||
| - | ||
| - public void insert‿heading_1() { | ||
| - insert‿heading( 1 ); | ||
| - } | ||
| - | ||
| - public void insert‿heading_2() { | ||
| - insert‿heading( 2 ); | ||
| - } | ||
| - | ||
| - public void insert‿heading_3() { | ||
| - insert‿heading( 3 ); | ||
| - } | ||
| - | ||
| - private void insert‿heading( final int level ) { | ||
| - getActiveTextEditor().heading( level ); | ||
| - } | ||
| - | ||
| - public void insert‿unordered_list() { | ||
| - getActiveTextEditor().unorderedList(); | ||
| - } | ||
| - | ||
| - public void insert‿ordered_list() { | ||
| - getActiveTextEditor().orderedList(); | ||
| - } | ||
| - | ||
| - public void insert‿horizontal_rule() { | ||
| - getActiveTextEditor().horizontalRule(); | ||
| - } | ||
| - | ||
| - public void definition‿create() { | ||
| - getActiveTextDefinition().createDefinition(); | ||
| - } | ||
| - | ||
| - public void definition‿rename() { | ||
| - getActiveTextDefinition().renameDefinition(); | ||
| - } | ||
| - | ||
| - public void definition‿delete() { | ||
| - getActiveTextDefinition().deleteDefinitions(); | ||
| - } | ||
| - | ||
| - public void definition‿autoinsert() { | ||
| - getMainPane().autoinsert(); | ||
| - } | ||
| - | ||
| - public void view‿refresh() { | ||
| - getMainPane().viewRefresh(); | ||
| - } | ||
| - | ||
| - public void view‿preview() { | ||
| - getMainPane().viewPreview(); | ||
| - } | ||
| - | ||
| - public void view‿outline() { | ||
| - getMainPane().viewOutline(); | ||
| - } | ||
| - | ||
| - public void view‿files() { getMainPane().viewFiles(); } | ||
| - | ||
| - public void view‿statistics() { | ||
| - getMainPane().viewStatistics(); | ||
| - } | ||
| - | ||
| - public void view‿menubar() { | ||
| - getMainScene().toggleMenuBar(); | ||
| - } | ||
| - | ||
| - public void view‿toolbar() { | ||
| - getMainScene().toggleToolBar(); | ||
| - } | ||
| - | ||
| - public void view‿statusbar() { | ||
| - getMainScene().toggleStatusBar(); | ||
| - } | ||
| - | ||
| - public void view‿log() { | ||
| - mLogView.view(); | ||
| - } | ||
| - | ||
| - public void help‿about() { | ||
| - final var alert = new Alert( INFORMATION ); | ||
| - final var prefix = "Dialog.about."; | ||
| - alert.setTitle( get( prefix + "title", APP_TITLE ) ); | ||
| - alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); | ||
| - alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); | ||
| - alert.setGraphic( ICON_DIALOG_NODE ); | ||
| - alert.initOwner( getWindow() ); | ||
| - alert.showAndWait(); | ||
| +import com.keenwrite.util.AlphanumComparator; | ||
| +import com.keenwrite.util.FileWalker; | ||
| +import com.vladsch.flexmark.ast.Link; | ||
| +import javafx.concurrent.Task; | ||
| +import javafx.scene.control.Alert; | ||
| +import javafx.scene.control.Dialog; | ||
| +import javafx.stage.Window; | ||
| +import javafx.stage.WindowEvent; | ||
| + | ||
| +import java.io.File; | ||
| +import java.io.IOException; | ||
| +import java.nio.file.Files; | ||
| +import java.nio.file.Path; | ||
| +import java.util.ArrayList; | ||
| +import java.util.List; | ||
| +import java.util.Optional; | ||
| +import java.util.concurrent.ExecutorService; | ||
| + | ||
| +import static com.keenwrite.Bootstrap.*; | ||
| +import static com.keenwrite.ExportFormat.*; | ||
| +import static com.keenwrite.Messages.get; | ||
| +import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; | ||
| +import static com.keenwrite.events.StatusEvent.clue; | ||
| +import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH; | ||
| +import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION; | ||
| +import static com.keenwrite.processors.ProcessorFactory.createProcessors; | ||
| +import static com.keenwrite.ui.explorer.FilePickerFactory.Options; | ||
| +import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*; | ||
| +import static java.nio.file.Files.*; | ||
| +import static java.nio.file.Files.writeString; | ||
| +import static java.util.concurrent.Executors.newFixedThreadPool; | ||
| +import static javafx.application.Platform.runLater; | ||
| +import static javafx.event.Event.fireEvent; | ||
| +import static javafx.scene.control.Alert.AlertType.INFORMATION; | ||
| +import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST; | ||
| +import static org.apache.commons.io.FilenameUtils.getExtension; | ||
| + | ||
| +/** | ||
| + * Responsible for abstracting how functionality is mapped to the application. | ||
| + * This allows users to customize accelerator keys and will provide pluggable | ||
| + * functionality so that different text markup languages can change documents | ||
| + * using their respective syntax. | ||
| + */ | ||
| +@SuppressWarnings( "NonAsciiCharacters" ) | ||
| +public final class ApplicationActions { | ||
| + private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | ||
| + | ||
| + private static final String STYLE_SEARCH = "search"; | ||
| + | ||
| + // Sci-fi genres typically fall below 150,000 words at 6 chars per word. | ||
| + private static final int AVERAGE_NOVEL_LENGTH = 150_000 * 6; | ||
| + | ||
| + /** | ||
| + * When an action is executed, this is one of the recipients. | ||
| + */ | ||
| + private final MainPane mMainPane; | ||
| + | ||
| + private final MainScene mMainScene; | ||
| + | ||
| + private final LogView mLogView; | ||
| + | ||
| + /** | ||
| + * Tracks finding text in the active document. | ||
| + */ | ||
| + private final SearchModel mSearchModel; | ||
| + | ||
| + public ApplicationActions( final MainScene scene, final MainPane pane ) { | ||
| + mMainScene = scene; | ||
| + mMainPane = pane; | ||
| + mLogView = new LogView(); | ||
| + mSearchModel = new SearchModel(); | ||
| + mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> { | ||
| + final var editor = getActiveTextEditor(); | ||
| + | ||
| + // Clear highlighted areas before highlighting a new region. | ||
| + if( o != null ) { | ||
| + editor.unstylize( STYLE_SEARCH ); | ||
| + } | ||
| + | ||
| + if( n != null ) { | ||
| + editor.moveTo( n.getStart() ); | ||
| + editor.stylize( n, STYLE_SEARCH ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + // When the active text editor changes, update the haystack. | ||
| + mMainPane.activeTextEditorProperty().addListener( | ||
| + ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() ) | ||
| + ); | ||
| + } | ||
| + | ||
| + public void file‿new() { | ||
| + getMainPane().newTextEditor(); | ||
| + } | ||
| + | ||
| + public void file‿open() { | ||
| + pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) ); | ||
| + } | ||
| + | ||
| + public void file‿close() { | ||
| + getMainPane().close(); | ||
| + } | ||
| + | ||
| + public void file‿close_all() { | ||
| + getMainPane().closeAll(); | ||
| + } | ||
| + | ||
| + public void file‿save() { | ||
| + getMainPane().save(); | ||
| + } | ||
| + | ||
| + public void file‿save_as() { | ||
| + pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) ); | ||
| + } | ||
| + | ||
| + public void file‿save_all() { | ||
| + getMainPane().saveAll(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Converts the actively edited file in the given file format. | ||
| + * | ||
| + * @param format The destination file format. | ||
| + */ | ||
| + private void file‿export( final ExportFormat format ) { | ||
| + file‿export( format, false ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Converts one or more files into the given file format. If {@code dir} | ||
| + * is set to true, this will first append all files in the same directory | ||
| + * as the actively edited file. | ||
| + * | ||
| + * @param format The destination file format. | ||
| + * @param dir Export all files in the actively edited file's directory. | ||
| + */ | ||
| + private void file‿export( final ExportFormat format, final boolean dir ) { | ||
| + final var main = getMainPane(); | ||
| + final var editor = main.getActiveTextEditor(); | ||
| + final var filename = format.toExportFilename( editor.getPath() ); | ||
| + final var selection = pickFiles( filename, FILE_EXPORT ); | ||
| + | ||
| + selection.ifPresent( ( files ) -> { | ||
| + final var file = files.get( 0 ); | ||
| + final var path = file.toPath(); | ||
| + final var document = dir ? append( editor ) : editor.getText(); | ||
| + final var context = main.createProcessorContext( path, format ); | ||
| + | ||
| + final var task = new Task<Path>() { | ||
| + @Override | ||
| + protected Path call() throws Exception { | ||
| + final var chain = createProcessors( context ); | ||
| + final var export = chain.apply( document ); | ||
| + | ||
| + // Processors can export binary files. In such cases, processors | ||
| + // return null to prevent further processing. | ||
| + return export == null ? null : writeString( path, export ); | ||
| + } | ||
| + }; | ||
| + | ||
| + task.setOnSucceeded( | ||
| + e -> { | ||
| + final var result = task.getValue(); | ||
| + | ||
| + // Binary formats must notify users of success independently. | ||
| + if( result != null ) { | ||
| + clue( "Main.status.export.success", result ); | ||
| + } | ||
| + } | ||
| + ); | ||
| + | ||
| + task.setOnFailed( e -> { | ||
| + final var ex = task.getException(); | ||
| + clue( ex ); | ||
| + | ||
| + if( ex instanceof TypeNotPresentException ) { | ||
| + fireExportFailedEvent(); | ||
| + } | ||
| + } ); | ||
| + | ||
| + sExecutor.execute( task ); | ||
| + } ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * @param dir {@code true} means to export all files in the active file | ||
| + * editor's directory; {@code false} means to export only the | ||
| + * actively edited file. | ||
| + */ | ||
| + private void file‿export‿pdf( final boolean dir ) { | ||
| + final var workspace = getWorkspace(); | ||
| + final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); | ||
| + final var theme = workspace.stringProperty( | ||
| + KEY_TYPESET_CONTEXT_THEME_SELECTION ); | ||
| + | ||
| + if( Typesetter.canRun() ) { | ||
| + // If the typesetter is installed, allow the user to select a theme. If | ||
| + // the themes aren't installed, a status message will appear. | ||
| + if( ThemePicker.choose( themes, theme ) ) { | ||
| + file‿export( APPLICATION_PDF, dir ); | ||
| + } | ||
| + } | ||
| + else { | ||
| + fireExportFailedEvent(); | ||
| + } | ||
| + } | ||
| + | ||
| + public void file‿export‿pdf() { | ||
| + file‿export‿pdf( false ); | ||
| + } | ||
| + | ||
| + public void file‿export‿pdf‿dir() { | ||
| + file‿export‿pdf( true ); | ||
| + } | ||
| + | ||
| + public void file‿export‿html_svg() { | ||
| + file‿export( HTML_TEX_SVG ); | ||
| + } | ||
| + | ||
| + public void file‿export‿html_tex() { | ||
| + file‿export( HTML_TEX_DELIMITED ); | ||
| + } | ||
| + | ||
| + public void file‿export‿xhtml_tex() { | ||
| + file‿export( XHTML_TEX ); | ||
| + } | ||
| + | ||
| + public void file‿export‿markdown() { | ||
| + file‿export( MARKDOWN_PLAIN ); | ||
| + } | ||
| + | ||
| + private void fireExportFailedEvent() { | ||
| + runLater( ExportFailedEvent::fireExportFailedEvent ); | ||
| + } | ||
| + | ||
| + public void file‿exit() { | ||
| + final var window = getWindow(); | ||
| + fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) ); | ||
| + } | ||
| + | ||
| + public void edit‿undo() { | ||
| + getActiveTextEditor().undo(); | ||
| + } | ||
| + | ||
| + public void edit‿redo() { | ||
| + getActiveTextEditor().redo(); | ||
| + } | ||
| + | ||
| + public void edit‿cut() { | ||
| + getActiveTextEditor().cut(); | ||
| + } | ||
| + | ||
| + public void edit‿copy() { | ||
| + getActiveTextEditor().copy(); | ||
| + } | ||
| + | ||
| + public void edit‿paste() { | ||
| + getActiveTextEditor().paste(); | ||
| + } | ||
| + | ||
| + public void edit‿select_all() { | ||
| + getActiveTextEditor().selectAll(); | ||
| + } | ||
| + | ||
| + public void edit‿find() { | ||
| + final var nodes = getMainScene().getStatusBar().getLeftItems(); | ||
| + | ||
| + if( nodes.isEmpty() ) { | ||
| + final var searchBar = new SearchBar(); | ||
| + | ||
| + searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() ); | ||
| + searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() ); | ||
| + | ||
| + searchBar.setOnCancelAction( ( event ) -> { | ||
| + final var editor = getActiveTextEditor(); | ||
| + nodes.remove( searchBar ); | ||
| + editor.unstylize( STYLE_SEARCH ); | ||
| + editor.getNode().requestFocus(); | ||
| + } ); | ||
| + | ||
| + searchBar.addInputListener( ( c, o, n ) -> { | ||
| + if( n != null && !n.isEmpty() ) { | ||
| + mSearchModel.search( n, getActiveTextEditor().getText() ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + searchBar.setOnNextAction( ( event ) -> edit‿find_next() ); | ||
| + searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() ); | ||
| + | ||
| + nodes.add( searchBar ); | ||
| + searchBar.requestFocus(); | ||
| + } | ||
| + else { | ||
| + nodes.clear(); | ||
| + } | ||
| + } | ||
| + | ||
| + public void edit‿find_next() { | ||
| + mSearchModel.advance(); | ||
| + } | ||
| + | ||
| + public void edit‿find_prev() { | ||
| + mSearchModel.retreat(); | ||
| + } | ||
| + | ||
| + public void edit‿preferences() { | ||
| + try { | ||
| + new PreferencesController( getWorkspace() ).show(); | ||
| + } catch( final Exception ex ) { | ||
| + clue( ex ); | ||
| + } | ||
| + } | ||
| + | ||
| + public void format‿bold() { | ||
| + getActiveTextEditor().bold(); | ||
| + } | ||
| + | ||
| + public void format‿italic() { | ||
| + getActiveTextEditor().italic(); | ||
| + } | ||
| + | ||
| + public void format‿superscript() { | ||
| + getActiveTextEditor().superscript(); | ||
| + } | ||
| + | ||
| + public void format‿subscript() { | ||
| + getActiveTextEditor().subscript(); | ||
| + } | ||
| + | ||
| + public void format‿strikethrough() { | ||
| + getActiveTextEditor().strikethrough(); | ||
| + } | ||
| + | ||
| + public void insert‿blockquote() { | ||
| + getActiveTextEditor().blockquote(); | ||
| + } | ||
| + | ||
| + public void insert‿code() { | ||
| + getActiveTextEditor().code(); | ||
| + } | ||
| + | ||
| + public void insert‿fenced_code_block() { | ||
| + getActiveTextEditor().fencedCodeBlock(); | ||
| + } | ||
| + | ||
| + public void insert‿link() { | ||
| + insertObject( createLinkDialog() ); | ||
| + } | ||
| + | ||
| + public void insert‿image() { | ||
| + insertObject( createImageDialog() ); | ||
| + } | ||
| + | ||
| + private void insertObject( final Dialog<String> dialog ) { | ||
| + final var textArea = getActiveTextEditor().getTextArea(); | ||
| + dialog.showAndWait().ifPresent( textArea::replaceSelection ); | ||
| + } | ||
| + | ||
| + private Dialog<String> createLinkDialog() { | ||
| + return new LinkDialog( getWindow(), createHyperlinkModel() ); | ||
| + } | ||
| + | ||
| + private Dialog<String> createImageDialog() { | ||
| + final var path = getActiveTextEditor().getPath(); | ||
| + final var parentDir = path.getParent(); | ||
| + return new ImageDialog( getWindow(), parentDir ); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns one of: selected text, word under cursor, or parsed hyperlink from | ||
| + * the Markdown AST. | ||
| + * | ||
| + * @return An instance containing the link URL and display text. | ||
| + */ | ||
| + private HyperlinkModel createHyperlinkModel() { | ||
| + final var context = getMainPane().createProcessorContext(); | ||
| + final var editor = getActiveTextEditor(); | ||
| + final var textArea = editor.getTextArea(); | ||
| + final var selectedText = textArea.getSelectedText(); | ||
| + | ||
| + // Convert current paragraph to Markdown nodes. | ||
| + final var mp = MarkdownProcessor.create( context ); | ||
| + final var p = textArea.getCurrentParagraph(); | ||
| + final var paragraph = textArea.getText( p ); | ||
| + final var node = mp.toNode( paragraph ); | ||
| + final var visitor = new LinkVisitor( textArea.getCaretColumn() ); | ||
| + final var link = visitor.process( node ); | ||
| + | ||
| + if( link != null ) { | ||
| + textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() ); | ||
| + } | ||
| + | ||
| + return createHyperlinkModel( link, selectedText ); | ||
| + } | ||
| + | ||
| + private HyperlinkModel createHyperlinkModel( | ||
| + final Link link, final String selection ) { | ||
| + | ||
| + return link == null | ||
| + ? new HyperlinkModel( selection, "https://localhost" ) | ||
| + : new HyperlinkModel( link ); | ||
| + } | ||
| + | ||
| + public void insert‿heading_1() { | ||
| + insert‿heading( 1 ); | ||
| + } | ||
| + | ||
| + public void insert‿heading_2() { | ||
| + insert‿heading( 2 ); | ||
| + } | ||
| + | ||
| + public void insert‿heading_3() { | ||
| + insert‿heading( 3 ); | ||
| + } | ||
| + | ||
| + private void insert‿heading( final int level ) { | ||
| + getActiveTextEditor().heading( level ); | ||
| + } | ||
| + | ||
| + public void insert‿unordered_list() { | ||
| + getActiveTextEditor().unorderedList(); | ||
| + } | ||
| + | ||
| + public void insert‿ordered_list() { | ||
| + getActiveTextEditor().orderedList(); | ||
| + } | ||
| + | ||
| + public void insert‿horizontal_rule() { | ||
| + getActiveTextEditor().horizontalRule(); | ||
| + } | ||
| + | ||
| + public void definition‿create() { | ||
| + getActiveTextDefinition().createDefinition(); | ||
| + } | ||
| + | ||
| + public void definition‿rename() { | ||
| + getActiveTextDefinition().renameDefinition(); | ||
| + } | ||
| + | ||
| + public void definition‿delete() { | ||
| + getActiveTextDefinition().deleteDefinitions(); | ||
| + } | ||
| + | ||
| + public void definition‿autoinsert() { | ||
| + getMainPane().autoinsert(); | ||
| + } | ||
| + | ||
| + public void view‿refresh() { | ||
| + getMainPane().viewRefresh(); | ||
| + } | ||
| + | ||
| + public void view‿preview() { | ||
| + getMainPane().viewPreview(); | ||
| + } | ||
| + | ||
| + public void view‿outline() { | ||
| + getMainPane().viewOutline(); | ||
| + } | ||
| + | ||
| + public void view‿files() { getMainPane().viewFiles(); } | ||
| + | ||
| + public void view‿statistics() { | ||
| + getMainPane().viewStatistics(); | ||
| + } | ||
| + | ||
| + public void view‿menubar() { | ||
| + getMainScene().toggleMenuBar(); | ||
| + } | ||
| + | ||
| + public void view‿toolbar() { | ||
| + getMainScene().toggleToolBar(); | ||
| + } | ||
| + | ||
| + public void view‿statusbar() { | ||
| + getMainScene().toggleStatusBar(); | ||
| + } | ||
| + | ||
| + public void view‿log() { | ||
| + mLogView.view(); | ||
| + } | ||
| + | ||
| + public void help‿about() { | ||
| + final var alert = new Alert( INFORMATION ); | ||
| + final var prefix = "Dialog.about."; | ||
| + alert.setTitle( get( prefix + "title", APP_TITLE ) ); | ||
| + alert.setHeaderText( get( prefix + "header", APP_TITLE ) ); | ||
| + alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) ); | ||
| + alert.setGraphic( ICON_DIALOG_NODE ); | ||
| + alert.initOwner( getWindow() ); | ||
| + alert.showAndWait(); | ||
| + } | ||
| + | ||
| + /** | ||
| + * Concatenates all the files in the same directory as the given file into | ||
| + * a string. The extension is determined by the given file name pattern; the | ||
| + * order files are concatenated is based on their numeric sort order (this | ||
| + * avoids lexicographic sorting). | ||
| + * <p> | ||
| + * If the parent path to the file being edited in the text editor cannot | ||
| + * be found then this will return the editor's text, without iterating through | ||
| + * the parent directory. (Should never happen, but who knows?) | ||
| + * </p> | ||
| + * <p> | ||
| + * New lines are automatically appended to separate each file. | ||
| + * </p> | ||
| + * | ||
| + * @param editor The text editor containing | ||
| + * @return All files in the same directory as the file being edited | ||
| + * concatenated into a single string. | ||
| + */ | ||
| + private String append( final TextEditor editor ) { | ||
| + final var pattern = editor.getPath(); | ||
| + final var parent = pattern.getParent(); | ||
| + | ||
| + // Short-circuit because nothing else can be done. | ||
| + if( parent == null ) { | ||
| + clue( "Main.status.export.concat.parent", pattern ); | ||
| + return editor.getText(); | ||
| + } | ||
| + | ||
| + final var filename = pattern.getFileName().toString(); | ||
| + final var extension = getExtension( filename ); | ||
| + | ||
| + if( extension == null || extension.isBlank() ) { | ||
| + clue( "Main.status.export.concat.extension", filename ); | ||
| + return editor.getText(); | ||
| + } | ||
| + | ||
| + try { | ||
| + final var glob = "**." + extension; | ||
| + final ArrayList<Path> files = new ArrayList<>(); | ||
| + FileWalker.walk( parent, glob, files::add ); | ||
| + files.sort( new AlphanumComparator<>() ); | ||
| + | ||
| + final var text = new StringBuilder( AVERAGE_NOVEL_LENGTH ); | ||
| + | ||
| + files.forEach( ( file ) -> { | ||
| + try { | ||
| + clue( "Main.status.export.concat", file ); | ||
| + text.append( readString( file ) ); | ||
| + } catch( final IOException ex ) { | ||
| + clue( "Main.status.export.concat.io", file ); | ||
| + } | ||
| + } ); | ||
| + | ||
| + return text.toString(); | ||
| + } catch( final Throwable t ) { | ||
| + clue( t ); | ||
| + return editor.getText(); | ||
| + } | ||
| } | ||
| .addSubActions( | ||
| addAction( "file.export.pdf", e -> actions.file‿export‿pdf() ), | ||
| + addAction( "file.export.pdf.dir", e -> actions.file‿export‿pdf‿dir() ), | ||
| addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ), | ||
| addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ), |
| +/* | ||
| + * The Alphanum Algorithm is an improved sorting algorithm for strings | ||
| + * containing numbers. Rather than sort numbers in ASCII order like | ||
| + * a standard sort, this algorithm sorts numbers in numeric order. | ||
| + * | ||
| + * The Alphanum Algorithm is discussed at http://www.DaveKoelle.com | ||
| + * | ||
| + * Released under the MIT License - https://opensource.org/licenses/MIT | ||
| + * | ||
| + * Copyright 2007-2017 David Koelle | ||
| + * | ||
| + * Permission is hereby granted, free of charge, to any person obtaining | ||
| + * a copy of this software and associated documentation files (the "Software"), | ||
| + * to deal in the Software without restriction, including without limitation | ||
| + * the rights to use, copy, modify, merge, publish, distribute, sublicense, | ||
| + * and/or sell copies of the Software, and to permit persons to whom the | ||
| + * Software is furnished to do so, subject to the following conditions: | ||
| + * | ||
| + * The above copyright notice and this permission notice shall be included | ||
| + * in all copies or substantial portions of the Software. | ||
| + * | ||
| + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
| + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | ||
| + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | ||
| + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | ||
| + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR | ||
| + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE | ||
| + * USE OR OTHER DEALINGS IN THE SOFTWARE. | ||
| + */ | ||
| +package com.keenwrite.util; | ||
| + | ||
| +import java.util.Comparator; | ||
| + | ||
| +import static java.lang.Character.isDigit; | ||
| + | ||
| +/** | ||
| + * Responsible for sorting lists that may contain numeric values. Usage: | ||
| + * <pre> | ||
| + * Collections.sort(list, new AlphanumComparator()); | ||
| + * </pre> | ||
| + * <p> | ||
| + * Where "list" is the list to sort alphanumerically, not lexicographically. | ||
| + * </p> | ||
| + */ | ||
| +public final class AlphanumComparator<T> implements Comparator<T> { | ||
| + /** | ||
| + * Returns a chunk of text that is continuous with respect to digits or | ||
| + * non-digits. | ||
| + * | ||
| + * @param s The string to compare. | ||
| + * @param length The string length, for improved efficiency. | ||
| + * @param marker The current index into a subset of the given string. | ||
| + * @return The substring {@code s} that is a continuous text chunk of the | ||
| + * same character type. | ||
| + */ | ||
| + private StringBuilder chunk( final String s, final int length, int marker ) { | ||
| + assert s != null; | ||
| + assert length >= 0; | ||
| + assert marker < length; | ||
| + | ||
| + // Prevent any possible memory re-allocations by using the length. | ||
| + final var chunk = new StringBuilder( length ); | ||
| + var c = s.charAt( marker ); | ||
| + final var chunkType = isDigit( c ); | ||
| + | ||
| + // While the character at the current position is the same type (numeric or | ||
| + // alphabetic), append the character to the current chunk. | ||
| + while( marker < length && | ||
| + isDigit( c = s.charAt( marker++ ) ) == chunkType ) { | ||
| + chunk.append( c ); | ||
| + } | ||
| + | ||
| + return chunk; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Performs an alphanumeric comparison of two strings, sorting numerically | ||
| + * first when numbers are found within the string. If either argument is | ||
| + * {@code null}, this will return zero. | ||
| + * | ||
| + * @param o1 The object to compare against {@code s2}, converted to string. | ||
| + * @param o2 The object to compare against {@code s1}, converted to string. | ||
| + * @return a negative integer, zero, or a positive integer if the first | ||
| + * argument is less than, equal to, or greater than the second, respectively. | ||
| + */ | ||
| + @Override | ||
| + public int compare( final T o1, final T o2 ) { | ||
| + if( o1 == null || o2 == null ) { | ||
| + return 0; | ||
| + } | ||
| + | ||
| + final var s1 = o1.toString(); | ||
| + final var s2 = o2.toString(); | ||
| + final var s1Length = s1.length(); | ||
| + final var s2Length = s2.length(); | ||
| + | ||
| + var thisMarker = 0; | ||
| + var thatMarker = 0; | ||
| + | ||
| + while( thisMarker < s1Length && thatMarker < s2Length ) { | ||
| + final var thisChunk = chunk( s1, s1Length, thisMarker ); | ||
| + final var thisChunkLength = thisChunk.length(); | ||
| + thisMarker += thisChunkLength; | ||
| + final var thatChunk = chunk( s2, s2Length, thatMarker ); | ||
| + final var thatChunkLength = thatChunk.length(); | ||
| + thatMarker += thatChunkLength; | ||
| + | ||
| + // If both chunks contain numeric characters, sort them numerically | ||
| + int result; | ||
| + | ||
| + if( isDigit( thisChunk.charAt( 0 ) ) && | ||
| + isDigit( thatChunk.charAt( 0 ) ) ) { | ||
| + // If equal, the first different number counts | ||
| + if( (result = thisChunkLength - thatChunkLength) == 0 ) { | ||
| + for( var i = 0; i < thisChunkLength; i++ ) { | ||
| + if( (result = thisChunk.charAt( i ) - thatChunk.charAt( i )) != 0 ) { | ||
| + return result; | ||
| + } | ||
| + } | ||
| + } | ||
| + } | ||
| + else { | ||
| + result = thisChunk.compareTo( thatChunk ); | ||
| + } | ||
| + | ||
| + if( result != 0 ) { | ||
| + return result; | ||
| + } | ||
| + } | ||
| + | ||
| + return s1Length - s2Length; | ||
| + } | ||
| +} | ||
| workspace.document.date.title=Timestamp | ||
| -workspace.r=R | ||
| -workspace.r.script=Startup Script | ||
| -workspace.r.script.desc=Script runs prior to executing R statements within the document. | ||
| -workspace.r.dir=Working Directory | ||
| -workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script. | ||
| -workspace.r.dir.title=Directory | ||
| -workspace.r.delimiter.began=Delimiter Prefix | ||
| -workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables. | ||
| -workspace.r.delimiter.began.title=Opening | ||
| -workspace.r.delimiter.ended=Delimiter Suffix | ||
| -workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables. | ||
| -workspace.r.delimiter.ended.title=Closing | ||
| - | ||
| -workspace.images=Images | ||
| -workspace.images.dir=Absolute Directory | ||
| -workspace.images.dir.desc=Path to search for local file system images. | ||
| -workspace.images.dir.title=Directory | ||
| -workspace.images.order=Extensions | ||
| -workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | ||
| -workspace.images.order.title=Extensions | ||
| -workspace.images.resize=Resize | ||
| -workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically. | ||
| -workspace.images.resize.title=Resize | ||
| - | ||
| -workspace.definition=Variable | ||
| -workspace.definition.path=File name | ||
| -workspace.definition.path.desc=Absolute path to interpolated string variables. | ||
| -workspace.definition.path.title=Path | ||
| -workspace.definition.delimiter.began=Delimiter Prefix | ||
| -workspace.definition.delimiter.began.desc=Indicates when a variable name is starting. | ||
| -workspace.definition.delimiter.began.title=Opening | ||
| -workspace.definition.delimiter.ended=Delimiter Suffix | ||
| -workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending. | ||
| -workspace.definition.delimiter.ended.title=Closing | ||
| - | ||
| -workspace.ui.skin=Skins | ||
| -workspace.ui.skin.selection=Bundled | ||
| -workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light). | ||
| -workspace.ui.skin.selection.title=Name | ||
| -workspace.ui.skin.custom=Custom | ||
| -workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file. | ||
| -workspace.ui.skin.custom.title=Path | ||
| - | ||
| -workspace.ui.font=Fonts | ||
| -workspace.ui.font.editor=Editor Font | ||
| -workspace.ui.font.editor.name=Name | ||
| -workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended). | ||
| -workspace.ui.font.editor.name.title=Family | ||
| -workspace.ui.font.editor.size=Size | ||
| -workspace.ui.font.editor.size.desc=Font size. | ||
| -workspace.ui.font.editor.size.title=Points | ||
| -workspace.ui.font.preview=Preview Font | ||
| -workspace.ui.font.preview.name=Name | ||
| -workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended). | ||
| -workspace.ui.font.preview.name.title=Family | ||
| -workspace.ui.font.preview.size=Size | ||
| -workspace.ui.font.preview.size.desc=Font size. | ||
| -workspace.ui.font.preview.size.title=Points | ||
| -workspace.ui.font.preview.mono.name=Name | ||
| -workspace.ui.font.preview.mono.name.desc=Monospace font name. | ||
| -workspace.ui.font.preview.mono.name.title=Family | ||
| -workspace.ui.font.preview.mono.size=Size | ||
| -workspace.ui.font.preview.mono.size.desc=Monospace font size. | ||
| -workspace.ui.font.preview.mono.size.title=Points | ||
| - | ||
| -workspace.language=Language | ||
| -workspace.language.locale=Internationalization | ||
| -workspace.language.locale.desc=Language for application and HTML export. | ||
| -workspace.language.locale.title=Locale | ||
| - | ||
| -workspace.typeset=Typesetting | ||
| -workspace.typeset.context=ConTeXt | ||
| -workspace.typeset.context.themes.path=Paths | ||
| -workspace.typeset.context.themes.path.desc=Directory containing theme subdirectories. | ||
| -workspace.typeset.context.themes.path.title=Themes | ||
| - | ||
| -# ######################################################################## | ||
| -# Menu Bar | ||
| -# ######################################################################## | ||
| - | ||
| -Main.menu.file=_File | ||
| -Main.menu.edit=_Edit | ||
| -Main.menu.insert=_Insert | ||
| -Main.menu.format=Forma_t | ||
| -Main.menu.definition=_Variable | ||
| -Main.menu.view=Vie_w | ||
| -Main.menu.help=_Help | ||
| - | ||
| -# ######################################################################## | ||
| -# Detachable Tabs | ||
| -# ######################################################################## | ||
| - | ||
| -# {0} is the application title; {1} is a unique window ID. | ||
| -Detach.tab.title={0} - {1} | ||
| - | ||
| -# ######################################################################## | ||
| -# Status Bar | ||
| -# ######################################################################## | ||
| - | ||
| -Main.status.text.offset=offset | ||
| -Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2} | ||
| -Main.status.state.default=OK | ||
| -Main.status.export.success=Saved as ''{0}'' | ||
| - | ||
| -Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found | ||
| - | ||
| -Main.status.error.parse={0} (near ${Main.status.text.offset} {1}) | ||
| -Main.status.error.def.blank=Move the caret to a word before inserting a variable | ||
| -Main.status.error.def.empty=Create a variable before inserting one | ||
| -Main.status.error.def.missing=No variable value found for ''{0}'' | ||
| -Main.status.error.r=Error with [{0}...]: {1} | ||
| -Main.status.error.file.missing=Not found: ''{0}'' | ||
| -Main.status.error.file.missing.near=Not found: ''{0}'' near line {1} | ||
| - | ||
| -Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | ||
| -Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | ||
| - | ||
| -Main.status.error.undo=Cannot undo; beginning of undo history reached | ||
| -Main.status.error.redo=Cannot redo; end of redo history reached | ||
| - | ||
| -Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'') | ||
| -Main.status.error.theme.name=Cannot find theme name for ''{0}'' | ||
| - | ||
| -Main.status.image.request.init=Initializing HTTP request | ||
| -Main.status.image.request.fetch=Requesting content type from ''{0}'' | ||
| -Main.status.image.request.success=Determined content type ''{0}'' | ||
| -Main.status.image.request.error.media=No media type for ''{0}'' | ||
| -Main.status.image.request.error.cert=Could not accept certificate for ''{0}'' | ||
| - | ||
| -Main.status.font.search.missing=No font name starting with ''{0}'' was found | ||
| - | ||
| -Main.status.typeset.create=Creating typesetter | ||
| -Main.status.typeset.xhtml=Export document as XHTML | ||
| -Main.status.typeset.began=Started typesetting ''{0}'' | ||
| -Main.status.typeset.failed=Could not generate PDF file | ||
| -Main.status.typeset.page=Typesetting page {0} of {1} (pass {2}) | ||
| -Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed) | ||
| -Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed) | ||
| - | ||
| -# ######################################################################## | ||
| -# Search Bar | ||
| -# ######################################################################## | ||
| - | ||
| -Main.search.stop.tooltip=Close search bar | ||
| -Main.search.stop.icon=CLOSE | ||
| -Main.search.next.tooltip=Find next match | ||
| -Main.search.next.icon=CHEVRON_DOWN | ||
| -Main.search.prev.tooltip=Find previous match | ||
| -Main.search.prev.icon=CHEVRON_UP | ||
| -Main.search.find.tooltip=Search document for text | ||
| -Main.search.find.icon=SEARCH | ||
| -Main.search.match.none=No matches | ||
| -Main.search.match.some={0} of {1} matches | ||
| - | ||
| -# ######################################################################## | ||
| -# Definition Pane and its Tree View | ||
| -# ######################################################################## | ||
| - | ||
| -Definition.menu.add.default=Undefined | ||
| - | ||
| -# ######################################################################## | ||
| -# Variable Definitions Pane | ||
| -# ######################################################################## | ||
| - | ||
| -Pane.definition.node.root.title=Variables | ||
| - | ||
| -# ######################################################################## | ||
| -# HTML Preview Pane | ||
| -# ######################################################################## | ||
| - | ||
| -Pane.preview.title=Preview | ||
| - | ||
| -# ######################################################################## | ||
| -# Document Outline Pane | ||
| -# ######################################################################## | ||
| - | ||
| -Pane.outline.title=Outline | ||
| - | ||
| -# ######################################################################## | ||
| -# File Manager Pane | ||
| -# ######################################################################## | ||
| - | ||
| -Pane.files.title=Files | ||
| - | ||
| -# ######################################################################## | ||
| -# Document Outline Pane | ||
| -# ######################################################################## | ||
| - | ||
| -Pane.statistics.title=Statistics | ||
| - | ||
| -# ######################################################################## | ||
| -# Failure messages with respect to YAML files. | ||
| -# ######################################################################## | ||
| - | ||
| -yaml.error.open=Could not open YAML file (ensure non-empty file). | ||
| -yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | ||
| -yaml.error.missing=Empty variable value for key ''{0}''. | ||
| -yaml.error.tree.form=Unassigned variable near ''{0}''. | ||
| - | ||
| -# ######################################################################## | ||
| -# Text Resource | ||
| -# ######################################################################## | ||
| - | ||
| -TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | ||
| -TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | ||
| - | ||
| -# ######################################################################## | ||
| -# Text Resources | ||
| -# ######################################################################## | ||
| - | ||
| -TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | ||
| -TextResource.saveFailed.title=Save | ||
| - | ||
| -# ######################################################################## | ||
| -# File Open | ||
| -# ######################################################################## | ||
| - | ||
| -Dialog.file.choose.open.title=Open File | ||
| -Dialog.file.choose.save.title=Save File | ||
| -Dialog.file.choose.export.title=Export File | ||
| - | ||
| -Dialog.file.choose.filter.title.source=Source Files | ||
| -Dialog.file.choose.filter.title.definition=Variable Files | ||
| -Dialog.file.choose.filter.title.xml=XML Files | ||
| -Dialog.file.choose.filter.title.all=All Files | ||
| - | ||
| -# ######################################################################## | ||
| -# Browse File | ||
| -# ######################################################################## | ||
| - | ||
| -BrowseFileButton.chooser.title=Open local file | ||
| -BrowseFileButton.chooser.allFilesFilter=All Files | ||
| -BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | ||
| - | ||
| -# ######################################################################## | ||
| -# Browse Directory | ||
| -# ######################################################################## | ||
| - | ||
| -BrowseDirectoryButton.chooser.title=Open local directory | ||
| -BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title} | ||
| - | ||
| -# ######################################################################## | ||
| -# Alert Dialog | ||
| -# ######################################################################## | ||
| - | ||
| -Alert.file.close.title=Close | ||
| -Alert.file.close.text=Save changes to {0}? | ||
| - | ||
| -# ######################################################################## | ||
| -# Typesetting Alert Dialog | ||
| -# ######################################################################## | ||
| - | ||
| -Alert.typesetter.missing.title=Missing Typesetter | ||
| -Alert.typesetter.missing.header=Install typesetter | ||
| -Alert.typesetter.missing.version=for {0} {1} {2}-bit | ||
| -Alert.typesetter.missing.installer.text=Download and install ConTeXt | ||
| -Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation | ||
| - | ||
| -# ######################################################################## | ||
| -# Image Dialog | ||
| -# ######################################################################## | ||
| - | ||
| -Dialog.image.title=Image | ||
| -Dialog.image.chooser.imagesFilter=Images | ||
| -Dialog.image.previewLabel.text=Markdown Preview\: | ||
| -Dialog.image.textLabel.text=Alternate Text\: | ||
| -Dialog.image.titleLabel.text=Title (tooltip)\: | ||
| -Dialog.image.urlLabel.text=Image URL\: | ||
| - | ||
| -# ######################################################################## | ||
| -# Hyperlink Dialog | ||
| -# ######################################################################## | ||
| - | ||
| -Dialog.link.title=Link | ||
| -Dialog.link.previewLabel.text=Markdown Preview\: | ||
| -Dialog.link.textLabel.text=Link Text\: | ||
| -Dialog.link.titleLabel.text=Title (tooltip)\: | ||
| -Dialog.link.urlLabel.text=Link URL\: | ||
| - | ||
| -# ######################################################################## | ||
| -# Themes Dialog | ||
| -# ######################################################################## | ||
| - | ||
| -Dialog.theme.title=Typesetting theme | ||
| -Dialog.theme.header=Choose a typesetting theme | ||
| - | ||
| -# ######################################################################## | ||
| -# About Dialog | ||
| -# ######################################################################## | ||
| - | ||
| -Dialog.about.title=About {0} | ||
| -Dialog.about.header={0} | ||
| -Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | ||
| - | ||
| -# ######################################################################## | ||
| -# Application Actions | ||
| -# ######################################################################## | ||
| - | ||
| -Action.file.new.description=Create a new file | ||
| -Action.file.new.accelerator=Shortcut+N | ||
| -Action.file.new.icon=FILE_ALT | ||
| -Action.file.new.text=_New | ||
| - | ||
| -Action.file.open.description=Open a new file | ||
| -Action.file.open.accelerator=Shortcut+O | ||
| -Action.file.open.text=_Open... | ||
| -Action.file.open.icon=FOLDER_OPEN_ALT | ||
| - | ||
| -Action.file.close.description=Close the current document | ||
| -Action.file.close.accelerator=Shortcut+W | ||
| -Action.file.close.text=_Close | ||
| - | ||
| -Action.file.close_all.description=Close all open documents | ||
| -Action.file.close_all.accelerator=Ctrl+F4 | ||
| -Action.file.close_all.text=Close All | ||
| - | ||
| -Action.file.save.description=Save the document | ||
| -Action.file.save.accelerator=Shortcut+S | ||
| -Action.file.save.text=_Save | ||
| -Action.file.save.icon=FLOPPY_ALT | ||
| - | ||
| -Action.file.save_as.description=Rename the current document | ||
| -Action.file.save_as.text=Save _As | ||
| - | ||
| -Action.file.save_all.description=Save all open documents | ||
| -Action.file.save_all.accelerator=Shortcut+Shift+S | ||
| -Action.file.save_all.text=Save A_ll | ||
| - | ||
| -Action.file.export.pdf.description=Typeset the document | ||
| -Action.file.export.pdf.accelerator=Shortcut+P | ||
| -Action.file.export.pdf.text=_PDF | ||
| -Action.file.export.pdf.icon=FILE_PDF_ALT | ||
| +workspace.typeset=Typesetting | ||
| +workspace.typeset.context=ConTeXt | ||
| +workspace.typeset.context.themes.path=Paths | ||
| +workspace.typeset.context.themes.path.desc=Directory containing theme subdirectories. | ||
| +workspace.typeset.context.themes.path.title=Themes | ||
| +workspace.typeset.context.clean=Clean | ||
| +workspace.typeset.context.clean.desc=Delete ancillary files after an unsuccessful export. | ||
| +workspace.typeset.context.clean.title=Purge | ||
| + | ||
| +workspace.r=R | ||
| +workspace.r.script=Startup Script | ||
| +workspace.r.script.desc=Script runs prior to executing R statements within the document. | ||
| +workspace.r.dir=Working Directory | ||
| +workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script. | ||
| +workspace.r.dir.title=Directory | ||
| +workspace.r.delimiter.began=Delimiter Prefix | ||
| +workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables. | ||
| +workspace.r.delimiter.began.title=Opening | ||
| +workspace.r.delimiter.ended=Delimiter Suffix | ||
| +workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables. | ||
| +workspace.r.delimiter.ended.title=Closing | ||
| + | ||
| +workspace.images=Images | ||
| +workspace.images.dir=Absolute Directory | ||
| +workspace.images.dir.desc=Path to search for local file system images. | ||
| +workspace.images.dir.title=Directory | ||
| +workspace.images.order=Extensions | ||
| +workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces. | ||
| +workspace.images.order.title=Extensions | ||
| +workspace.images.resize=Resize | ||
| +workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically. | ||
| +workspace.images.resize.title=Resize | ||
| + | ||
| +workspace.definition=Variable | ||
| +workspace.definition.path=File name | ||
| +workspace.definition.path.desc=Absolute path to interpolated string variables. | ||
| +workspace.definition.path.title=Path | ||
| +workspace.definition.delimiter.began=Delimiter Prefix | ||
| +workspace.definition.delimiter.began.desc=Indicates when a variable name is starting. | ||
| +workspace.definition.delimiter.began.title=Opening | ||
| +workspace.definition.delimiter.ended=Delimiter Suffix | ||
| +workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending. | ||
| +workspace.definition.delimiter.ended.title=Closing | ||
| + | ||
| +workspace.ui.skin=Skins | ||
| +workspace.ui.skin.selection=Bundled | ||
| +workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light). | ||
| +workspace.ui.skin.selection.title=Name | ||
| +workspace.ui.skin.custom=Custom | ||
| +workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file. | ||
| +workspace.ui.skin.custom.title=Path | ||
| + | ||
| +workspace.ui.font=Fonts | ||
| +workspace.ui.font.editor=Editor Font | ||
| +workspace.ui.font.editor.name=Name | ||
| +workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended). | ||
| +workspace.ui.font.editor.name.title=Family | ||
| +workspace.ui.font.editor.size=Size | ||
| +workspace.ui.font.editor.size.desc=Font size. | ||
| +workspace.ui.font.editor.size.title=Points | ||
| +workspace.ui.font.preview=Preview Font | ||
| +workspace.ui.font.preview.name=Name | ||
| +workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended). | ||
| +workspace.ui.font.preview.name.title=Family | ||
| +workspace.ui.font.preview.size=Size | ||
| +workspace.ui.font.preview.size.desc=Font size. | ||
| +workspace.ui.font.preview.size.title=Points | ||
| +workspace.ui.font.preview.mono.name=Name | ||
| +workspace.ui.font.preview.mono.name.desc=Monospace font name. | ||
| +workspace.ui.font.preview.mono.name.title=Family | ||
| +workspace.ui.font.preview.mono.size=Size | ||
| +workspace.ui.font.preview.mono.size.desc=Monospace font size. | ||
| +workspace.ui.font.preview.mono.size.title=Points | ||
| + | ||
| +workspace.language=Language | ||
| +workspace.language.locale=Internationalization | ||
| +workspace.language.locale.desc=Language for application and HTML export. | ||
| +workspace.language.locale.title=Locale | ||
| + | ||
| +# ######################################################################## | ||
| +# Menu Bar | ||
| +# ######################################################################## | ||
| + | ||
| +Main.menu.file=_File | ||
| +Main.menu.edit=_Edit | ||
| +Main.menu.insert=_Insert | ||
| +Main.menu.format=Forma_t | ||
| +Main.menu.definition=_Variable | ||
| +Main.menu.view=Vie_w | ||
| +Main.menu.help=_Help | ||
| + | ||
| +# ######################################################################## | ||
| +# Detachable Tabs | ||
| +# ######################################################################## | ||
| + | ||
| +# {0} is the application title; {1} is a unique window ID. | ||
| +Detach.tab.title={0} - {1} | ||
| + | ||
| +# ######################################################################## | ||
| +# Status Bar | ||
| +# ######################################################################## | ||
| + | ||
| +Main.status.text.offset=offset | ||
| +Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2} | ||
| +Main.status.state.default=OK | ||
| +Main.status.export.success=Saved as ''{0}'' | ||
| + | ||
| +Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found | ||
| + | ||
| +Main.status.error.parse={0} (near ${Main.status.text.offset} {1}) | ||
| +Main.status.error.def.blank=Move the caret to a word before inserting a variable | ||
| +Main.status.error.def.empty=Create a variable before inserting one | ||
| +Main.status.error.def.missing=No variable value found for ''{0}'' | ||
| +Main.status.error.r=Error with [{0}...]: {1} | ||
| +Main.status.error.file.missing=Not found: ''{0}'' | ||
| +Main.status.error.file.missing.near=Not found: ''{0}'' near line {1} | ||
| + | ||
| +Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}'' | ||
| +Main.status.error.messages.syntax=Missing ''}'' in ''{0}'' | ||
| + | ||
| +Main.status.error.undo=Cannot undo; beginning of undo history reached | ||
| +Main.status.error.redo=Cannot redo; end of redo history reached | ||
| + | ||
| +Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'') | ||
| +Main.status.error.theme.name=Cannot find theme name for ''{0}'' | ||
| + | ||
| +Main.status.image.request.init=Initializing HTTP request | ||
| +Main.status.image.request.fetch=Requesting content type from ''{0}'' | ||
| +Main.status.image.request.success=Determined content type ''{0}'' | ||
| +Main.status.image.request.error.media=No media type for ''{0}'' | ||
| +Main.status.image.request.error.cert=Could not accept certificate for ''{0}'' | ||
| + | ||
| +Main.status.font.search.missing=No font name starting with ''{0}'' was found | ||
| + | ||
| +Main.status.export.concat=Concatenating ''{0}'' | ||
| +Main.status.export.concat.parent=No parent directory found for ''{0}'' | ||
| +Main.status.export.concat.extension=File name must have an extension ''{0}'' | ||
| +Main.status.export.concat.io=Could not read from ''{0}'' | ||
| + | ||
| +Main.status.typeset.create=Creating typesetter | ||
| +Main.status.typeset.xhtml=Export document as XHTML | ||
| +Main.status.typeset.began=Started typesetting ''{0}'' | ||
| +Main.status.typeset.failed=Could not generate PDF file | ||
| +Main.status.typeset.page=Typesetting page {0} of {1} (pass {2}) | ||
| +Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed) | ||
| +Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed) | ||
| + | ||
| +# ######################################################################## | ||
| +# Search Bar | ||
| +# ######################################################################## | ||
| + | ||
| +Main.search.stop.tooltip=Close search bar | ||
| +Main.search.stop.icon=CLOSE | ||
| +Main.search.next.tooltip=Find next match | ||
| +Main.search.next.icon=CHEVRON_DOWN | ||
| +Main.search.prev.tooltip=Find previous match | ||
| +Main.search.prev.icon=CHEVRON_UP | ||
| +Main.search.find.tooltip=Search document for text | ||
| +Main.search.find.icon=SEARCH | ||
| +Main.search.match.none=No matches | ||
| +Main.search.match.some={0} of {1} matches | ||
| + | ||
| +# ######################################################################## | ||
| +# Definition Pane and its Tree View | ||
| +# ######################################################################## | ||
| + | ||
| +Definition.menu.add.default=Undefined | ||
| + | ||
| +# ######################################################################## | ||
| +# Variable Definitions Pane | ||
| +# ######################################################################## | ||
| + | ||
| +Pane.definition.node.root.title=Variables | ||
| + | ||
| +# ######################################################################## | ||
| +# HTML Preview Pane | ||
| +# ######################################################################## | ||
| + | ||
| +Pane.preview.title=Preview | ||
| + | ||
| +# ######################################################################## | ||
| +# Document Outline Pane | ||
| +# ######################################################################## | ||
| + | ||
| +Pane.outline.title=Outline | ||
| + | ||
| +# ######################################################################## | ||
| +# File Manager Pane | ||
| +# ######################################################################## | ||
| + | ||
| +Pane.files.title=Files | ||
| + | ||
| +# ######################################################################## | ||
| +# Document Outline Pane | ||
| +# ######################################################################## | ||
| + | ||
| +Pane.statistics.title=Statistics | ||
| + | ||
| +# ######################################################################## | ||
| +# Failure messages with respect to YAML files. | ||
| +# ######################################################################## | ||
| + | ||
| +yaml.error.open=Could not open YAML file (ensure non-empty file). | ||
| +yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | ||
| +yaml.error.missing=Empty variable value for key ''{0}''. | ||
| +yaml.error.tree.form=Unassigned variable near ''{0}''. | ||
| + | ||
| +# ######################################################################## | ||
| +# Text Resource | ||
| +# ######################################################################## | ||
| + | ||
| +TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | ||
| +TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | ||
| + | ||
| +# ######################################################################## | ||
| +# Text Resources | ||
| +# ######################################################################## | ||
| + | ||
| +TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | ||
| +TextResource.saveFailed.title=Save | ||
| + | ||
| +# ######################################################################## | ||
| +# File Open | ||
| +# ######################################################################## | ||
| + | ||
| +Dialog.file.choose.open.title=Open File | ||
| +Dialog.file.choose.save.title=Save File | ||
| +Dialog.file.choose.export.title=Export File | ||
| + | ||
| +Dialog.file.choose.filter.title.source=Source Files | ||
| +Dialog.file.choose.filter.title.definition=Variable Files | ||
| +Dialog.file.choose.filter.title.xml=XML Files | ||
| +Dialog.file.choose.filter.title.all=All Files | ||
| + | ||
| +# ######################################################################## | ||
| +# Browse File | ||
| +# ######################################################################## | ||
| + | ||
| +BrowseFileButton.chooser.title=Open local file | ||
| +BrowseFileButton.chooser.allFilesFilter=All Files | ||
| +BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | ||
| + | ||
| +# ######################################################################## | ||
| +# Browse Directory | ||
| +# ######################################################################## | ||
| + | ||
| +BrowseDirectoryButton.chooser.title=Open local directory | ||
| +BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title} | ||
| + | ||
| +# ######################################################################## | ||
| +# Alert Dialog | ||
| +# ######################################################################## | ||
| + | ||
| +Alert.file.close.title=Close | ||
| +Alert.file.close.text=Save changes to {0}? | ||
| + | ||
| +# ######################################################################## | ||
| +# Typesetting Alert Dialog | ||
| +# ######################################################################## | ||
| + | ||
| +Alert.typesetter.missing.title=Missing Typesetter | ||
| +Alert.typesetter.missing.header=Install typesetter | ||
| +Alert.typesetter.missing.version=for {0} {1} {2}-bit | ||
| +Alert.typesetter.missing.installer.text=Download and install ConTeXt | ||
| +Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation | ||
| + | ||
| +# ######################################################################## | ||
| +# Image Dialog | ||
| +# ######################################################################## | ||
| + | ||
| +Dialog.image.title=Image | ||
| +Dialog.image.chooser.imagesFilter=Images | ||
| +Dialog.image.previewLabel.text=Markdown Preview\: | ||
| +Dialog.image.textLabel.text=Alternate Text\: | ||
| +Dialog.image.titleLabel.text=Title (tooltip)\: | ||
| +Dialog.image.urlLabel.text=Image URL\: | ||
| + | ||
| +# ######################################################################## | ||
| +# Hyperlink Dialog | ||
| +# ######################################################################## | ||
| + | ||
| +Dialog.link.title=Link | ||
| +Dialog.link.previewLabel.text=Markdown Preview\: | ||
| +Dialog.link.textLabel.text=Link Text\: | ||
| +Dialog.link.titleLabel.text=Title (tooltip)\: | ||
| +Dialog.link.urlLabel.text=Link URL\: | ||
| + | ||
| +# ######################################################################## | ||
| +# Themes Dialog | ||
| +# ######################################################################## | ||
| + | ||
| +Dialog.theme.title=Typesetting theme | ||
| +Dialog.theme.header=Choose a typesetting theme | ||
| + | ||
| +# ######################################################################## | ||
| +# About Dialog | ||
| +# ######################################################################## | ||
| + | ||
| +Dialog.about.title=About {0} | ||
| +Dialog.about.header={0} | ||
| +Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | ||
| + | ||
| +# ######################################################################## | ||
| +# Application Actions | ||
| +# ######################################################################## | ||
| + | ||
| +Action.file.new.description=Create a new file | ||
| +Action.file.new.accelerator=Shortcut+N | ||
| +Action.file.new.icon=FILE_ALT | ||
| +Action.file.new.text=_New | ||
| + | ||
| +Action.file.open.description=Open a new file | ||
| +Action.file.open.accelerator=Shortcut+O | ||
| +Action.file.open.text=_Open... | ||
| +Action.file.open.icon=FOLDER_OPEN_ALT | ||
| + | ||
| +Action.file.close.description=Close the current document | ||
| +Action.file.close.accelerator=Shortcut+W | ||
| +Action.file.close.text=_Close | ||
| + | ||
| +Action.file.close_all.description=Close all open documents | ||
| +Action.file.close_all.accelerator=Ctrl+F4 | ||
| +Action.file.close_all.text=Close All | ||
| + | ||
| +Action.file.save.description=Save the document | ||
| +Action.file.save.accelerator=Shortcut+S | ||
| +Action.file.save.text=_Save | ||
| +Action.file.save.icon=FLOPPY_ALT | ||
| + | ||
| +Action.file.save_as.description=Rename the current document | ||
| +Action.file.save_as.text=Save _As | ||
| + | ||
| +Action.file.save_all.description=Save all open documents | ||
| +Action.file.save_all.accelerator=Shortcut+Shift+S | ||
| +Action.file.save_all.text=Save A_ll | ||
| + | ||
| +Action.file.export.pdf.description=Typeset the document | ||
| +Action.file.export.pdf.accelerator=Shortcut+P | ||
| +Action.file.export.pdf.text=_PDF | ||
| +Action.file.export.pdf.icon=FILE_PDF_ALT | ||
| + | ||
| +Action.file.export.pdf.dir.description=Typeset files in document directory | ||
| +Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P | ||
| +Action.file.export.pdf.dir.text=_Joined PDF | ||
| +Action.file.export.pdf.dir.icon=FILE_PDF_ALT | ||
| Action.file.export.html_svg.description=Export the current document as HTML + SVG |
| +/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | ||
| +package com.keenwrite.util; | ||
| + | ||
| +import org.junit.jupiter.api.Test; | ||
| + | ||
| +import java.util.ArrayList; | ||
| +import java.util.Arrays; | ||
| +import java.util.Collections; | ||
| + | ||
| +import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| + | ||
| +/** | ||
| + * Responsible for testing the http://www.davekoelle.com/alphanum.html | ||
| + * implementation. | ||
| + */ | ||
| +class AlphanumComparatorTest { | ||
| + | ||
| + /** | ||
| + * Test that a randomly sorted list containing a mix of alphanumeric | ||
| + * characters ("chunks") will be sorted according to numeric and alphabetic | ||
| + * order. | ||
| + */ | ||
| + @Test | ||
| + public void test_Sort_UnsortedList_SortedAlphanumerically() { | ||
| + final var expected = Arrays.asList( | ||
| + "10X Radonius", | ||
| + "20X Radonius", | ||
| + "20X Radonius Prime", | ||
| + "30X Radonius", | ||
| + "40X Radonius", | ||
| + "200X Radonius", | ||
| + "1000X Radonius Maximus", | ||
| + "Allegia 6R Clasteron", | ||
| + "Allegia 50 Clasteron", | ||
| + "Allegia 50B Clasteron", | ||
| + "Allegia 51 Clasteron", | ||
| + "Allegia 500 Clasteron", | ||
| + "Alpha 2", | ||
| + "Alpha 2A", | ||
| + "Alpha 2A-900", | ||
| + "Alpha 2A-8000", | ||
| + "Alpha 100", | ||
| + "Alpha 200", | ||
| + "Callisto Morphamax", | ||
| + "Callisto Morphamax 500", | ||
| + "Callisto Morphamax 600", | ||
| + "Callisto Morphamax 700", | ||
| + "Callisto Morphamax 5000", | ||
| + "Callisto Morphamax 6000 SE", | ||
| + "Callisto Morphamax 6000 SE2", | ||
| + "Callisto Morphamax 7000", | ||
| + "Xiph Xlater 5", | ||
| + "Xiph Xlater 40", | ||
| + "Xiph Xlater 50", | ||
| + "Xiph Xlater 58", | ||
| + "Xiph Xlater 300", | ||
| + "Xiph Xlater 500", | ||
| + "Xiph Xlater 2000", | ||
| + "Xiph Xlater 5000", | ||
| + "Xiph Xlater 10000" | ||
| + ); | ||
| + final var actual = new ArrayList<>( expected ); | ||
| + | ||
| + Collections.shuffle( actual ); | ||
| + actual.sort( new AlphanumComparator<>() ); | ||
| + assertEquals( expected, actual ); | ||
| + } | ||
| +} | ||
| Delta | 1481 lines added, 1148 lines removed, 333-line increase |
|---|