Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M R/conversion.R
8585
# Translates a number from digits to words using Chicago Manual of Style.
8686
# This will translate numbers greater than one by truncating to nearest
87
# thousandth, millionth, billionth, etc. regardless of oridinal. If ordinal
88
# is TRUE, this will return the ordinal name. This will not produce ordinals
89
# for numbers greater than 100.
90
#
91
# If scaled is TRUE, this will write large numbers as comma-separated values.
92
# -----------------------------------------------------------------------------
93
cms <- function( n, ordinal = FALSE, scaled = TRUE ) {
94
  n <- x( n )
95
96
  if( n == 0 ) {
97
    if( ordinal ) {
98
      return( "zeroth" )
99
    }
100
101
    return( "zero" )
102
  }
103
104
  # Concatenate this a little later.
105
  if( n < 0 ) {
106
    result = "negative "
107
    n = abs( n )
108
  }
109
110
  if( n > 999 && scaled ) {
111
    scales <- c(
112
      "thousand", "million", "billion", "trillion", "quadrillion",
113
      "quintillion", "sextillion", "septillion", "octillion", "nonillion",
114
      "decillion", "undecillion", "duodecillion", "tredecillion",
115
      "quattuordecillion", "quindecillion", "sexdecillion", "septendecillion",
116
      "octodecillion", "novemdecillion", "vigintillion", "centillion",
117
      "quadrillion", "quitillion", "sextillion"
118
    );
119
120
    d <- round( n / (10 ^ (log10( n ) - log10( n ) %% 3)) );
121
    n <- floor( log10( n ) ) / 3;
122
    return( paste( cms( d ), scales[ n ] ) );
123
  }
124
125
  # Do not spell out numbers greater than one hundred.
126
  if( n > 100 ) {
127
    # Comma-separated numbers.
128
    return( commas( n ) )
129
  }
130
131
  # Don't go beyond 100.
132
  if( n == 100 ) {
133
    if( ordinal ) {
134
      return( "one hundredth" )
135
    }
136
137
    return( "one hundred" )
138
  }
139
140
  # Samuel Langhorne Clemens noted English has too many exceptions.
141
  small = c(
142
    "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
143
    "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
144
    "seventeen", "eighteen", "nineteen"
145
  )
146
147
  ord_small = c(
148
    "first", "second", "third", "fourth", "fifth", "sixth", "seventh",
149
    "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth",
150
    "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth",
151
    "nineteenth", "twentieth"
152
  )
153
154
  # After this, the number (n) is between 20 and 99.
155
  if( n < 20 ) {
156
    if( ordinal ) {
157
      return( .subset( ord_small, n %% 100 ) )
158
    }
159
160
    return( .subset( small, n %% 100 ) )
161
  }
162
163
  tens = c( "",
164
    "twenty", "thirty", "forty", "fifty",
165
    "sixty", "seventy", "eighty", "ninety"
166
  )
167
168
  ord_tens = c( "",
169
    "twentieth", "thirtieth", "fortieth", "fiftieth",
170
    "sixtieth", "seventieth", "eightieth", "ninetieth"
171
  )
172
173
  ones_index = n %% 10
174
  n = n %/% 10
175
176
  # No number in the ones column, so the number must be a multiple of ten.
177
  if( ones_index == 0 ) {
178
    if( ordinal ) {
179
      return( .subset( ord_tens, n ) )
180
    }
181
182
    return( .subset( tens, n ) )
183
  }
184
185
  # Find the value from the ones column.
186
  if( ordinal ) {
187
    unit_1 = .subset( ord_small, ones_index )
188
  }
189
  else {
190
    unit_1 = .subset( small, ones_index )
191
  }
192
193
  # Find the tens column.
194
  unit_10 = .subset( tens, n )
195
196
  # Hyphenate the tens and the ones together.
197
  concat( unit_10, concat( "-", unit_1 ) )
198
}
199
200
# -----------------------------------------------------------------------------
201
# Returns a number as a comma-delimited string. This is a work-around
202
# until Renjin fixes https://github.com/bedatadriven/renjin/issues/338
203
# -----------------------------------------------------------------------------
204
commas <- function( n ) {
205
  n <- x( n )
206
207
  s <- sprintf( "%03.0f", n %% 1000 )
208
  n <- n %/% 1000
209
210
  while( n > 0 ) {
211
    s <- concat( sprintf( "%03.0f", n %% 1000 ), ',', s )
212
    n <- n %/% 1000
213
  }
214
215
  gsub( "^0*", '', s )
216
}
217
218
# -----------------------------------------------------------------------------
219
# Returns a human-readable string that provides the elapsed time between
220
# two numbers in terms of years, months, and days. If any unit value is zero,
221
# the unit is not included. The words (year, month, day) are pluralized
222
# according to English grammar. The numbers are written out according to
223
# Chicago Manual of Style. This applies the serial comma.
224
#
225
# Both numbers are offsets relative to the anchor date.
226
#
227
# If all unit values are zero, this returns s ("same day" by default).
228
#
229
# If the start date (began) is greater than end date (ended), the dates are
230
# swapped before calculations are performed. This allows any two dates
231
# to be compared and positive unit values are always returned.
232
# -----------------------------------------------------------------------------
233
elapsed <- function( began, ended, s = "same day" ) {
234
  began = when( anchor, began )
235
  ended = when( anchor, ended )
236
237
  # Swap the dates if the end date comes before the start date.
238
  if( as.integer( ended - began ) < 0 ) {
239
    tempd = began
240
    began = ended
241
    ended = tempd
242
  }
243
244
  # Calculate number of elapsed years.
245
  years = length( seq( from = began, to = ended, by = "year" ) ) - 1
246
247
  # Move the start date up by the number of elapsed years.
248
  if( years > 0 ) {
249
    began = seq( began, length = 2, by = concat( years, " years" ) )[2]
250
    years = pl.numeric( "year", years )
251
  }
252
  else {
253
    # Zero years.
254
    years = ""
255
  }
256
257
  # Calculate number of elapsed months, excluding years.
258
  months = length( seq( from = began, to = ended, by = "month" ) ) - 1
259
260
  # Move the start date up by the number of elapsed months
261
  if( months > 0 ) {
262
    began = seq( began, length = 2, by = concat( months, " months" ) )[2]
263
    months = pl.numeric( "month", months )
264
  }
265
  else {
266
    # Zero months
267
    months = ""
268
  }
269
270
  # Calculate number of elapsed days, excluding months and years.
271
  days = length( seq( from = began, to = ended, by = "day" ) ) - 1
272
273
  if( days > 0 ) {
274
    days = pl.numeric( "day", days )
275
  }
276
  else {
277
    # Zero days
278
    days = ""
279
  }
280
281
  if( years <= 0 && months <= 0 && days <= 0 ) {
282
    return( s )
283
  }
284
285
  # Put them all in a vector, then remove the empty values.
286
  s <- c( years, months, days )
287
  s <- s[ s != "" ]
288
289
  r <- paste( s, collapse = ", " )
290
291
  # If all three items are present, replace the last comma with ", and".
292
  if( length( s ) > 2 ) {
293
    return( gsub( "(.*),", "\\1, and", r ) )
294
  }
295
296
  # Does nothing if no commas are present.
297
  gsub( "(.*),", "\\1 and", r )
298
}
299
300
# -----------------------------------------------------------------------------
301
# Returns the number (n) in English followed by the plural or singular
302
# form of the given string (s; resumably a noun), if applicable, according
303
# to English grammar. That is, pl.numeric( "wolf", 5 ) will return
304
# "five wolves".
305
# -----------------------------------------------------------------------------
306
pl.numeric <- function( s, n ) {
307
  concat( cms( n ), concat( " ", pluralize( word=s, n=n ) ) )
308
}
309
310
# -----------------------------------------------------------------------------
311
# Pluralize s if n is not equal to 1.
312
# -----------------------------------------------------------------------------
313
pl <- function( s, count=2 ) {
314
  pluralize( word=s, n=count )
315
}
316
317
# -----------------------------------------------------------------------------
318
# Name of the season, starting with an capital letter.
319
# -----------------------------------------------------------------------------
320
season <- function( n, format = "%Y-%m-%d" ) {
321
  WS <- as.Date("2016-12-15", "%Y-%m-%d") # Winter Solstice
322
  SE <- as.Date("2016-03-15", "%Y-%m-%d") # Spring Equinox
323
  SS <- as.Date("2016-06-15", "%Y-%m-%d") # Summer Solstice
324
  AE <- as.Date("2016-09-15", "%Y-%m-%d") # Autumn Equinox
325
326
  d <- when( anchor, n )
327
  d <- as.Date( strftime( d, format="2016-%m-%d" ) )
328
329
  ifelse( d >= WS | d < SE, "Winter",
330
    ifelse( d >= SE & d < SS, "Spring",
331
      ifelse( d >= SS & d < AE, "Summer", "Autumn" )
332
    )
333
  )
334
}
335
336
# -----------------------------------------------------------------------------
337
# Converts the first letter in a string to lowercase
338
# -----------------------------------------------------------------------------
339
lc <- function( s ) {
340
  concat( tolower( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) )
341
}
342
343
# -----------------------------------------------------------------------------
344
# Converts the entire string to lowercase
345
# -----------------------------------------------------------------------------
346
lower <- tolower
347
348
# -----------------------------------------------------------------------------
349
# Converts the first letter in a string to uppercase
350
# -----------------------------------------------------------------------------
351
uc <- function( s ) {
352
  concat( toupper( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) )
353
}
354
355
# -----------------------------------------------------------------------------
356
# Returns the number of days between the given dates.
357
# -----------------------------------------------------------------------------
358
days <- function( d1, d2, format = "%Y-%m-%d" ) {
359
  dates = c( d1, d2 )
360
  dt = strptime( dates, format = format )
361
  as.integer( difftime( dates[2], dates[1], units = "days" ) )
362
}
363
364
weeks <- function( began, ended ) {
365
  began = when( anchor, began )
366
  ended = when( anchor, ended )
367
368
  if( as.integer( ended - began ) < 0 ) {
369
    tempd = began
370
    began = ended
371
    ended = tempd
372
  }
373
374
  # Calculate number of elapsed weeks.
375
  length( seq( from = began, to = ended, by = "weeks" ) ) - 1
376
}
377
378
# -----------------------------------------------------------------------------
379
# Returns the number of years elapsed.
380
# -----------------------------------------------------------------------------
381
years <- function( began, ended ) {
382
  began = when( anchor, began )
383
  ended = when( anchor, ended )
384
385
  # Swap the dates if the end date comes before the start date.
386
  if( as.integer( ended - began ) < 0 ) {
387
    tempd = began
388
    began = ended
389
    ended = tempd
390
  }
391
392
  # Calculate number of elapsed years.
393
  length( seq( from = began, to = ended, by = "year" ) ) - 1
394
}
395
396
# -----------------------------------------------------------------------------
397
# Full name of the month, starting with a capital letter.
398
# -----------------------------------------------------------------------------
399
month <- function( n ) {
400
  # Faster than month.name[ x( n ) ]
401
  .subset( month.name, x( n ) )
402
}
403
404
# -----------------------------------------------------------------------------
405
# -----------------------------------------------------------------------------
406
money <- function( n ) {
407
  commas( x( n ) )
408
}
409
410
# -----------------------------------------------------------------------------
411
# -----------------------------------------------------------------------------
412
timeline <- function( n ) {
413
  concat( weekday( n ), ", ", annal( n ), " (", season( n ), ")" )
414
}
415
416
# -----------------------------------------------------------------------------
417
# Rounds to the nearest base value (e.g., round to nearest 10).
418
#
419
# @param base The nearest value to round to.
420
# -----------------------------------------------------------------------------
421
round.up <- function( n, base = 5 ) {
422
  base * round( x( n ) / base )
423
}
424
425
# -----------------------------------------------------------------------------
426
# Removes common accents from letters.
427
#
428
# @param s The string to remove diacritics from.
87
# thousandth, millionth, billionth, etc. regardless of ordinal. If ordinal
88
# is TRUE, this will return the ordinal name. This will not produce ordinals
89
# for numbers greater than 100.
90
#
91
# If scaled is TRUE, this will write large numbers as comma-separated values.
92
# -----------------------------------------------------------------------------
93
cms <- function( n, ordinal = FALSE, scaled = TRUE ) {
94
  n <- x( n )
95
96
  if( n == 0 ) {
97
    if( ordinal ) {
98
      return( "zeroth" )
99
    }
100
101
    return( "zero" )
102
  }
103
104
  # Concatenate this a little later.
105
  if( n < 0 ) {
106
    result = "negative "
107
    n = abs( n )
108
  }
109
110
  if( n > 999 && scaled ) {
111
    scales <- c(
112
      "thousand", "million", "billion", "trillion", "quadrillion",
113
      "quintillion", "sextillion", "septillion", "octillion", "nonillion",
114
      "decillion", "undecillion", "duodecillion", "tredecillion",
115
      "quattuordecillion", "quindecillion", "sexdecillion", "septendecillion",
116
      "octodecillion", "novemdecillion", "vigintillion", "centillion",
117
      "quadrillion", "quitillion", "sextillion"
118
    );
119
120
    d <- round( n / (10 ^ (log10( n ) - log10( n ) %% 3)) );
121
    n <- floor( log10( n ) ) / 3;
122
    return( paste( cms( d ), scales[ n ] ) );
123
  }
124
125
  # Do not spell out numbers greater than one hundred.
126
  if( n > 100 ) {
127
    # Comma-separated numbers.
128
    return( commas( n ) )
129
  }
130
131
  # Don't go beyond 100.
132
  if( n == 100 ) {
133
    if( ordinal ) {
134
      return( "one hundredth" )
135
    }
136
137
    return( "one hundred" )
138
  }
139
140
  # Samuel Langhorne Clemens noted English has too many exceptions.
141
  small = c(
142
    "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
143
    "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
144
    "seventeen", "eighteen", "nineteen"
145
  )
146
147
  ord_small = c(
148
    "first", "second", "third", "fourth", "fifth", "sixth", "seventh",
149
    "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth",
150
    "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth",
151
    "nineteenth", "twentieth"
152
  )
153
154
  # After this, the number (n) is between 20 and 99.
155
  if( n < 20 ) {
156
    if( ordinal ) {
157
      return( .subset( ord_small, n %% 100 ) )
158
    }
159
160
    return( .subset( small, n %% 100 ) )
161
  }
162
163
  tens = c( "",
164
    "twenty", "thirty", "forty", "fifty",
165
    "sixty", "seventy", "eighty", "ninety"
166
  )
167
168
  ord_tens = c( "",
169
    "twentieth", "thirtieth", "fortieth", "fiftieth",
170
    "sixtieth", "seventieth", "eightieth", "ninetieth"
171
  )
172
173
  ones_index = n %% 10
174
  n = n %/% 10
175
176
  # No number in the ones column, so the number must be a multiple of ten.
177
  if( ones_index == 0 ) {
178
    if( ordinal ) {
179
      return( .subset( ord_tens, n ) )
180
    }
181
182
    return( .subset( tens, n ) )
183
  }
184
185
  # Find the value from the ones column.
186
  if( ordinal ) {
187
    unit_1 = .subset( ord_small, ones_index )
188
  }
189
  else {
190
    unit_1 = .subset( small, ones_index )
191
  }
192
193
  # Find the tens column.
194
  unit_10 = .subset( tens, n )
195
196
  # Hyphenate the tens and the ones together.
197
  concat( unit_10, concat( "-", unit_1 ) )
198
}
199
200
# -----------------------------------------------------------------------------
201
# Returns a number as a comma-delimited string. This is a work-around
202
# until Renjin fixes https://github.com/bedatadriven/renjin/issues/338
203
# -----------------------------------------------------------------------------
204
commas <- function( n ) {
205
  n <- x( n )
206
207
  s <- sprintf( "%03.0f", n %% 1000 )
208
  n <- n %/% 1000
209
210
  while( n > 0 ) {
211
    s <- concat( sprintf( "%03.0f", n %% 1000 ), ',', s )
212
    n <- n %/% 1000
213
  }
214
215
  gsub( "^0*", '', s )
216
}
217
218
# -----------------------------------------------------------------------------
219
# Returns a human-readable string that provides the elapsed time between
220
# two numbers in terms of years, months, and days. If any unit value is zero,
221
# the unit is not included. The words (year, month, day) are pluralized
222
# according to English grammar. The numbers are written out according to
223
# Chicago Manual of Style. This applies the serial comma.
224
#
225
# Both numbers are offsets relative to the anchor date.
226
#
227
# If all unit values are zero, this returns s ("same day" by default).
228
#
229
# If the start date (began) is greater than end date (ended), the dates are
230
# swapped before calculations are performed. This allows any two dates
231
# to be compared and positive unit values are always returned.
232
# -----------------------------------------------------------------------------
233
elapsed <- function( began, ended, s = "same day" ) {
234
  began = when( anchor, began )
235
  ended = when( anchor, ended )
236
237
  # Swap the dates if the end date comes before the start date.
238
  if( as.integer( ended - began ) < 0 ) {
239
    tempd = began
240
    began = ended
241
    ended = tempd
242
  }
243
244
  # Calculate number of elapsed years.
245
  years = length( seq( from = began, to = ended, by = "year" ) ) - 1
246
247
  # Move the start date up by the number of elapsed years.
248
  if( years > 0 ) {
249
    began = seq( began, length = 2, by = concat( years, " years" ) )[2]
250
    years = pl.numeric( "year", years )
251
  }
252
  else {
253
    # Zero years.
254
    years = ""
255
  }
256
257
  # Calculate number of elapsed months, excluding years.
258
  months = length( seq( from = began, to = ended, by = "month" ) ) - 1
259
260
  # Move the start date up by the number of elapsed months
261
  if( months > 0 ) {
262
    began = seq( began, length = 2, by = concat( months, " months" ) )[2]
263
    months = pl.numeric( "month", months )
264
  }
265
  else {
266
    # Zero months
267
    months = ""
268
  }
269
270
  # Calculate number of elapsed days, excluding months and years.
271
  days = length( seq( from = began, to = ended, by = "day" ) ) - 1
272
273
  if( days > 0 ) {
274
    days = pl.numeric( "day", days )
275
  }
276
  else {
277
    # Zero days
278
    days = ""
279
  }
280
281
  if( years <= 0 && months <= 0 && days <= 0 ) {
282
    return( s )
283
  }
284
285
  # Put them all in a vector, then remove the empty values.
286
  s <- c( years, months, days )
287
  s <- s[ s != "" ]
288
289
  r <- paste( s, collapse = ", " )
290
291
  # If all three items are present, replace the last comma with ", and".
292
  if( length( s ) > 2 ) {
293
    return( gsub( "(.*),", "\\1, and", r ) )
294
  }
295
296
  # Does nothing if no commas are present.
297
  gsub( "(.*),", "\\1 and", r )
298
}
299
300
# -----------------------------------------------------------------------------
301
# Returns the number (n) in English followed by the plural or singular
302
# form of the given string (s; resumably a noun), if applicable, according
303
# to English grammar. That is, pl.numeric( "wolf", 5 ) will return
304
# "five wolves".
305
# -----------------------------------------------------------------------------
306
pl.numeric <- function( s, n ) {
307
  concat( cms( n ), concat( " ", pluralize( word=s, n=n ) ) )
308
}
309
310
# -----------------------------------------------------------------------------
311
# Pluralize s if n is not equal to 1.
312
# -----------------------------------------------------------------------------
313
pl <- function( s, count=2 ) {
314
  pluralize( word=s, n=count )
315
}
316
317
# -----------------------------------------------------------------------------
318
# Name of the season, starting with an capital letter.
319
# -----------------------------------------------------------------------------
320
season <- function( n, format = "%Y-%m-%d" ) {
321
  WS <- as.Date("2016-12-15", "%Y-%m-%d") # Winter Solstice
322
  SE <- as.Date("2016-03-15", "%Y-%m-%d") # Spring Equinox
323
  SS <- as.Date("2016-06-15", "%Y-%m-%d") # Summer Solstice
324
  AE <- as.Date("2016-09-15", "%Y-%m-%d") # Autumn Equinox
325
326
  d <- when( anchor, n )
327
  d <- as.Date( strftime( d, format="2016-%m-%d" ) )
328
329
  ifelse( d >= WS | d < SE, "Winter",
330
    ifelse( d >= SE & d < SS, "Spring",
331
      ifelse( d >= SS & d < AE, "Summer", "Autumn" )
332
    )
333
  )
334
}
335
336
# -----------------------------------------------------------------------------
337
# Converts the first letter in a string to lowercase
338
# -----------------------------------------------------------------------------
339
lc <- function( s ) {
340
  concat( tolower( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) )
341
}
342
343
# -----------------------------------------------------------------------------
344
# Converts the entire string to lowercase
345
# -----------------------------------------------------------------------------
346
lower <- tolower
347
348
# -----------------------------------------------------------------------------
349
# Converts the first letter in a string to uppercase
350
# -----------------------------------------------------------------------------
351
uc <- function( s ) {
352
  concat( toupper( substr( s, 1, 1 ) ), substr( s, 2, nchar( s ) ) )
353
}
354
355
# -----------------------------------------------------------------------------
356
# Returns the number of time periods that have elapsed.
357
# -----------------------------------------------------------------------------
358
time.elapsed <- function( began, ended, by = "year" ) {
359
  began = when( anchor, began )
360
  ended = when( anchor, ended )
361
362
  # Swap the dates if the end date comes before the start date.
363
  if( as.integer( ended - began ) < 0 ) {
364
    tempd = began
365
    began = ended
366
    ended = tempd
367
  }
368
369
  # Calculate the elapsed time period.
370
  length( seq( from = began, to = ended, by = by ) ) - 1
371
}
372
373
# -----------------------------------------------------------------------------
374
# Returns the number of days between the given dates, taking into account
375
# the passage of years.
376
# -----------------------------------------------------------------------------
377
days <- function( d1, d2, format = "%Y-%m-%d" ) {
378
  dates = c( d1, d2 )
379
  dt = strptime( dates, format = format )
380
381
  as.integer( difftime( dates[2], dates[1], units = "days" ) )
382
}
383
384
# -----------------------------------------------------------------------------
385
# Returns the number of elapsed weeks.
386
# -----------------------------------------------------------------------------
387
weeks <- function( began, ended ) {
388
  time.elapsed( began, ended, "weeks" );
389
}
390
391
# -----------------------------------------------------------------------------
392
# Returns the number of elapsed months.
393
# -----------------------------------------------------------------------------
394
months <- function( began, ended ) {
395
  time.elapsed( began, ended, "months" );
396
}
397
398
# -----------------------------------------------------------------------------
399
# Returns the number of elapsed years.
400
# -----------------------------------------------------------------------------
401
years <- function( began, ended ) {
402
  time.elapsed( began, ended, "years" );
403
}
404
405
# -----------------------------------------------------------------------------
406
# Full name of the month, starting with a capital letter.
407
# -----------------------------------------------------------------------------
408
month <- function( n ) {
409
  # Faster than month.name[ x( n ) ]
410
  .subset( month.name, x( n ) )
411
}
412
413
# -----------------------------------------------------------------------------
414
# -----------------------------------------------------------------------------
415
money <- function( n ) {
416
  commas( x( n ) )
417
}
418
419
# -----------------------------------------------------------------------------
420
# -----------------------------------------------------------------------------
421
timeline <- function( n ) {
422
  concat( weekday( n ), ", ", annal( n ), " (", season( n ), ")" )
423
}
424
425
# -----------------------------------------------------------------------------
426
# Rounds to the nearest base value (e.g., round to nearest 10).
427
#
428
# @param base The nearest value to round to.
429
# -----------------------------------------------------------------------------
430
round.up <- function( n, base = 5 ) {
431
  base * round( x( n ) / base )
432
}
433
434
# -----------------------------------------------------------------------------
435
# Removes common accents from letters.
436
#
437
# @param s The string to remove diacritics from.
438
#
439
# @return The given string without diacritics.
429440
# -----------------------------------------------------------------------------
430441
accentless <- function( s ) {
M R/pluralize.R
458458
    "medium",
459459
    "memorandum",
460
    "millenium",
460
    "millennium",
461461
    "minimum",
462462
    "momentum",
M R.zip
Binary file
M README.md
3535
3636
1. Download the *Full version* of the Java Runtime Environment, [JRE 20](https://bell-sw.com/pages/downloads).
37
  * Note that both Java 20+ and JavaFX are required. The *Full version* of
38
    BellSoft's JRE satisifies these requirements.
3739
1. Install the JRE (include JRE's `bin` directory in the `PATH` environment variable).
3840
1. Open a new terminal.
M build.gradle
8989
  def v_junit = '5.9.3'
9090
  def v_flexmark = '0.64.6'
91
  def v_jackson = '2.15.1'
92
  def v_echosvg = '0.3'
93
  def v_picocli = '4.7.3'
91
  def v_jackson = '2.15.2'
92
  def v_echosvg = '0.3.1'
93
  def v_picocli = '4.7.4'
9494
9595
  // JavaFX
...
124124
  // R
125125
  implementation 'org.apache.commons:commons-compress:1.23.0'
126
  implementation 'org.codehaus.plexus:plexus-utils:3.5.1'
126
  implementation 'org.codehaus.plexus:plexus-utils:4.0.0'
127127
  implementation 'org.renjin:renjin-script-engine:3.5-beta76'
128128
  implementation 'org.renjin.cran:rjson:0.2.15-renjin-21'
M container/manage.sh
246246
    $log "Loaded ${CONTAINER_SHORTNAME} image"
247247
  else
248
    warning "Missing ${CONTAINER_COMPRESSED_PATH}; use build follwed by save"
248
    warning "Missing ${CONTAINER_COMPRESSED_PATH}; use build followed by save"
249249
  fi
250250
}
M docs/i18n.md
2323
* **Noto Sans CJK SC** --- Simplified Chinese font
2424
25
While CJK font familes for the preview have the following names:
25
While CJK font families for the preview have the following names:
2626
2727
* **Noto Serif CJK KR** --- Korean font
M keenwrite.sh
11
#!/usr/bin/env bash
22
3
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)
4
35
java \
46
  --add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \
...
1517
  --add-exports=javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED \
1618
  --add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED \
17
  -jar keenwrite.jar $@
19
  -jar ${SCRIPT_DIR}/keenwrite.jar $@
1820
1921
M src/main/java/com/keenwrite/MainPane.java
3535
import com.panemu.tiwulfx.control.dock.DetachableTab;
3636
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
37
import javafx.application.Platform;
38
import javafx.beans.property.*;
39
import javafx.collections.ListChangeListener;
40
import javafx.concurrent.Task;
41
import javafx.event.ActionEvent;
42
import javafx.event.Event;
43
import javafx.event.EventHandler;
44
import javafx.scene.Node;
45
import javafx.scene.Scene;
46
import javafx.scene.control.SplitPane;
47
import javafx.scene.control.Tab;
48
import javafx.scene.control.TabPane;
49
import javafx.scene.control.Tooltip;
50
import javafx.scene.control.TreeItem.TreeModificationEvent;
51
import javafx.scene.input.KeyEvent;
52
import javafx.stage.Stage;
53
import javafx.stage.Window;
54
import org.greenrobot.eventbus.Subscribe;
55
56
import java.io.File;
57
import java.io.FileNotFoundException;
58
import java.nio.file.Path;
59
import java.util.*;
60
import java.util.concurrent.ExecutorService;
61
import java.util.concurrent.ScheduledExecutorService;
62
import java.util.concurrent.ScheduledFuture;
63
import java.util.concurrent.atomic.AtomicBoolean;
64
import java.util.concurrent.atomic.AtomicReference;
65
import java.util.function.Consumer;
66
import java.util.function.Function;
67
import java.util.stream.Collectors;
68
69
import static com.keenwrite.ExportFormat.NONE;
70
import static com.keenwrite.Launcher.terminate;
71
import static com.keenwrite.Messages.get;
72
import static com.keenwrite.constants.Constants.*;
73
import static com.keenwrite.events.Bus.register;
74
import static com.keenwrite.events.StatusEvent.clue;
75
import static com.keenwrite.io.MediaType.*;
76
import static com.keenwrite.io.MediaType.TypeName.TEXT;
77
import static com.keenwrite.preferences.AppKeys.*;
78
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
79
import static com.keenwrite.processors.ProcessorContext.Mutator;
80
import static com.keenwrite.processors.ProcessorContext.builder;
81
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
82
import static java.awt.Desktop.getDesktop;
83
import static java.util.concurrent.Executors.newFixedThreadPool;
84
import static java.util.concurrent.Executors.newScheduledThreadPool;
85
import static java.util.concurrent.TimeUnit.SECONDS;
86
import static java.util.stream.Collectors.groupingBy;
87
import static javafx.application.Platform.runLater;
88
import static javafx.scene.control.ButtonType.NO;
89
import static javafx.scene.control.ButtonType.YES;
90
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
91
import static javafx.scene.input.KeyCode.ENTER;
92
import static javafx.scene.input.KeyCode.SPACE;
93
import static javafx.scene.input.KeyCombination.ALT_DOWN;
94
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
95
import static javafx.util.Duration.millis;
96
import static javax.swing.SwingUtilities.invokeLater;
97
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
98
99
/**
100
 * Responsible for wiring together the main application components for a
101
 * particular {@link Workspace} (project). These include the definition views,
102
 * text editors, and preview pane along with any corresponding controllers.
103
 */
104
public final class MainPane extends SplitPane {
105
106
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
107
  private static final Notifier sNotifier = Services.load( Notifier.class );
108
109
  /**
110
   * Used when opening files to determine how each file should be binned and
111
   * therefore what tab pane to be opened within.
112
   */
113
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
114
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
115
  );
116
117
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
118
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
119
    new AtomicReference<>();
120
121
  /**
122
   * Prevents re-instantiation of processing classes.
123
   */
124
  private final Map<TextResource, Processor<String>> mProcessors =
125
    new HashMap<>();
126
127
  private final Workspace mWorkspace;
128
129
  /**
130
   * Groups similar file type tabs together.
131
   */
132
  private final List<TabPane> mTabPanes = new ArrayList<>();
133
134
  /**
135
   * Renders the actively selected plain text editor tab.
136
   */
137
  private final HtmlPreview mPreview;
138
139
  /**
140
   * Provides an interactive document outline.
141
   */
142
  private final DocumentOutline mOutline = new DocumentOutline();
143
144
  /**
145
   * Changing the active editor fires the value changed event. This allows
146
   * refreshes to happen when external definitions are modified and need to
147
   * trigger the processing chain.
148
   */
149
  private final ObjectProperty<TextEditor> mTextEditor =
150
    createActiveTextEditor();
151
152
  /**
153
   * Changing the active definition editor fires the value changed event. This
154
   * allows refreshes to happen when external definitions are modified and need
155
   * to trigger the processing chain.
156
   */
157
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
158
159
  private final ObjectProperty<SpellChecker> mSpellChecker;
160
161
  private final TextEditorSpellChecker mEditorSpeller;
162
163
  /**
164
   * Called when the definition data is changed.
165
   */
166
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
167
    event -> {
168
      process( getTextEditor() );
169
      save( getTextDefinition() );
170
    };
171
172
  /**
173
   * Tracks the number of detached tab panels opened into their own windows,
174
   * which allows unique identification of subordinate windows by their title.
175
   * It is doubtful more than 128 windows, much less 256, will be created.
176
   */
177
  private byte mWindowCount;
178
179
  private final VariableNameInjector mVariableNameInjector;
180
181
  private final RBootstrapController mRBootstrapController;
182
183
  private final DocumentStatistics mStatistics;
184
185
  @SuppressWarnings( {"FieldCanBeLocal", "unused"} )
186
  private final TypesetterInstaller mInstallWizard;
187
188
  /**
189
   * Adds all content panels to the main user interface. This will load the
190
   * configuration settings from the workspace to reproduce the settings from
191
   * a previous session.
192
   */
193
  public MainPane( final Workspace workspace ) {
194
    mWorkspace = workspace;
195
    mSpellChecker = createSpellChecker();
196
    mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
197
    mPreview = new HtmlPreview( workspace );
198
    mStatistics = new DocumentStatistics( workspace );
199
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
200
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
201
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
202
    mRBootstrapController = new RBootstrapController(
203
      mWorkspace, this::getDefinitions );
204
205
    open( collect( getRecentFiles() ) );
206
    viewPreview();
207
    setDividerPositions( calculateDividerPositions() );
208
209
    // Once the main scene's window regains focus, update the active definition
210
    // editor to the currently selected tab.
211
    runLater( () -> getWindow().setOnCloseRequest( event -> {
212
      // Order matters: Open file names must be persisted before closing all.
213
      mWorkspace.save();
214
215
      if( closeAll() ) {
216
        Platform.exit();
217
        terminate( 0 );
218
      }
219
220
      event.consume();
221
    } ) );
222
223
    register( this );
224
    initAutosave( workspace );
225
226
    restoreSession();
227
    runLater( this::restoreFocus );
228
229
    mInstallWizard = new TypesetterInstaller( workspace );
230
  }
231
232
  /**
233
   * Called when spellchecking can be run. This will reload the dictionary
234
   * into memory once, and then re-use it for all the existing text editors.
235
   *
236
   * @param event The event to process, having a populated word-frequency map.
237
   */
238
  @Subscribe
239
  public void handle( final LexiconLoadedEvent event ) {
240
    final var lexicon = event.getLexicon();
241
242
    try {
243
      final var checker = SymSpellSpeller.forLexicon( lexicon );
244
      mSpellChecker.set( checker );
245
    } catch( final Exception ex ) {
246
      clue( ex );
247
    }
248
  }
249
250
  @Subscribe
251
  public void handle( final TextEditorFocusEvent event ) {
252
    mTextEditor.set( event.get() );
253
  }
254
255
  @Subscribe
256
  public void handle( final TextDefinitionFocusEvent event ) {
257
    mDefinitionEditor.set( event.get() );
258
  }
259
260
  /**
261
   * Typically called when a file name is clicked in the preview panel.
262
   *
263
   * @param event The event to process, must contain a valid file reference.
264
   */
265
  @Subscribe
266
  public void handle( final FileOpenEvent event ) {
267
    final File eventFile;
268
    final var eventUri = event.getUri();
269
270
    if( eventUri.isAbsolute() ) {
271
      eventFile = new File( eventUri.getPath() );
272
    }
273
    else {
274
      final var activeFile = getTextEditor().getFile();
275
      final var parent = activeFile.getParentFile();
276
277
      if( parent == null ) {
278
        clue( new FileNotFoundException( eventUri.getPath() ) );
279
        return;
280
      }
281
      else {
282
        final var parentPath = parent.getAbsolutePath();
283
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
284
      }
285
    }
286
287
    final var mediaType = MediaTypeExtension.fromFile( eventFile );
288
289
    runLater( () -> {
290
      // Open text files locally.
291
      if( mediaType.isType( TEXT ) ) {
292
        open( eventFile );
293
      }
294
      else {
295
        try {
296
          // Delegate opening all other file types to the operating system.
297
          getDesktop().open( eventFile );
298
        } catch( final Exception ex ) {
299
          clue( ex );
300
        }
301
      }
302
    } );
303
  }
304
305
  @Subscribe
306
  public void handle( final CaretNavigationEvent event ) {
307
    runLater( () -> {
308
      final var textArea = getTextEditor();
309
      textArea.moveTo( event.getOffset() );
310
      textArea.requestFocus();
311
    } );
312
  }
313
314
  @Subscribe
315
  public void handle( final InsertDefinitionEvent<String> event ) {
316
    final var leaf = event.getLeaf();
317
    final var editor = mTextEditor.get();
318
319
    mVariableNameInjector.insert( editor, leaf );
320
  }
321
322
  private void initAutosave( final Workspace workspace ) {
323
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
324
325
    rate.addListener(
326
      ( c, o, n ) -> {
327
        final var taskRef = mSaveTask.get();
328
329
        // Prevent multiple autosaves from running.
330
        if( taskRef != null ) {
331
          taskRef.cancel( false );
332
        }
333
334
        initAutosave( rate );
335
      }
336
    );
337
338
    // Start the save listener (avoids duplicating some code).
339
    initAutosave( rate );
340
  }
341
342
  private void initAutosave( final IntegerProperty rate ) {
343
    mSaveTask.set(
344
      mSaver.scheduleAtFixedRate(
345
        () -> {
346
          if( getTextEditor().isModified() ) {
347
            // Ensure the modified indicator is cleared by running on EDT.
348
            runLater( this::save );
349
          }
350
        }, 0, rate.intValue(), SECONDS
351
      )
352
    );
353
  }
354
355
  /**
356
   * TODO: Load divider positions from exported settings, see
357
   *   {@link #collect(SetProperty)} comment.
358
   */
359
  private double[] calculateDividerPositions() {
360
    final var ratio = 100f / getItems().size() / 100;
361
    final var positions = getDividerPositions();
362
363
    for( int i = 0; i < positions.length; i++ ) {
364
      positions[ i ] = ratio * i;
365
    }
366
367
    return positions;
368
  }
369
370
  /**
371
   * Opens all the files into the application, provided the paths are unique.
372
   * This may only be called for any type of files that a user can edit
373
   * (i.e., update and persist), such as definitions and text files.
374
   *
375
   * @param files The list of files to open.
376
   */
377
  public void open( final List<File> files ) {
378
    files.forEach( this::open );
379
  }
380
381
  /**
382
   * This opens the given file. Since the preview pane is not a file that
383
   * can be opened, it is safe to add a listener to the detachable pane.
384
   * This will exit early if the given file is not a regular file (i.e., a
385
   * directory).
386
   *
387
   * @param inputFile The file to open.
388
   */
389
  private void open( final File inputFile ) {
390
    // Prevent opening directories (a non-existent "untitled.md" is fine).
391
    if( !inputFile.isFile() && inputFile.exists() ) {
392
      return;
393
    }
394
395
    final var tab = createTab( inputFile );
396
    final var node = tab.getContent();
397
    final var mediaType = MediaType.valueFrom( inputFile );
398
    final var tabPane = obtainTabPane( mediaType );
399
400
    tab.setTooltip( createTooltip( inputFile ) );
401
    tabPane.setFocusTraversable( false );
402
    tabPane.setTabClosingPolicy( ALL_TABS );
403
    tabPane.getTabs().add( tab );
404
405
    // Attach the tab scene factory for new tab panes.
406
    if( !getItems().contains( tabPane ) ) {
407
      addTabPane(
408
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
409
      );
410
    }
411
412
    if( inputFile.isFile() ) {
413
      getRecentFiles().add( inputFile.getAbsolutePath() );
414
    }
415
  }
416
417
  /**
418
   * Gives focus to the most recently edited document and attempts to move
419
   * the caret to the most recently known offset into said document.
420
   */
421
  private void restoreSession() {
422
    final var workspace = getWorkspace();
423
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
424
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
425
426
    for( final var pane : mTabPanes ) {
427
      for( final var tab : pane.getTabs() ) {
428
        final var tooltip = tab.getTooltip();
429
430
        if( tooltip != null ) {
431
          final var tabName = tooltip.getText();
432
          final var fileName = file.getValue().toString();
433
434
          if( tabName.equalsIgnoreCase( fileName ) ) {
435
            final var node = tab.getContent();
436
437
            pane.getSelectionModel().select( tab );
438
            node.requestFocus();
439
440
            if( node instanceof TextEditor editor ) {
441
              editor.moveTo( offset.getValue() );
442
            }
443
444
            break;
445
          }
446
        }
447
      }
448
    }
449
  }
450
451
  /**
452
   * Sets the focus to the middle pane, which contains the text editor tabs.
453
   */
454
  private void restoreFocus() {
455
    // Work around a bug where focusing directly on the middle pane results
456
    // in the R engine not loading variables properly.
457
    mTabPanes.get( 0 ).requestFocus();
458
459
    // This is the only line that should be required.
460
    mTabPanes.get( 1 ).requestFocus();
461
  }
462
463
  /**
464
   * Opens a new text editor document using the default document file name.
465
   */
466
  public void newTextEditor() {
467
    open( DOCUMENT_DEFAULT );
468
  }
469
470
  /**
471
   * Opens a new definition editor document using the default definition
472
   * file name.
473
   */
474
  public void newDefinitionEditor() {
475
    open( DEFINITION_DEFAULT );
476
  }
477
478
  /**
479
   * Iterates over all tab panes to find all {@link TextEditor}s and request
480
   * that they save themselves.
481
   */
482
  public void saveAll() {
483
    iterateEditors( this::save );
484
  }
485
486
  /**
487
   * Requests that the active {@link TextEditor} saves itself. Don't bother
488
   * checking if modified first because if the user swaps external media from
489
   * an external source (e.g., USB thumb drive), save should not second-guess
490
   * the user: save always re-saves. Also, it's less code.
491
   */
492
  public void save() {
493
    save( getTextEditor() );
494
  }
495
496
  /**
497
   * Saves the active {@link TextEditor} under a new name.
498
   *
499
   * @param files The new active editor {@link File} reference, must contain
500
   *              at least one element.
501
   */
502
  public void saveAs( final List<File> files ) {
503
    assert files != null;
504
    assert !files.isEmpty();
505
    final var editor = getTextEditor();
506
    final var tab = getTab( editor );
507
    final var file = files.get( 0 );
508
509
    // If the file type has changed, refresh the processors.
510
    final var mediaType = MediaType.valueFrom( file );
511
    final var typeChanged = !editor.isMediaType( mediaType );
512
513
    if( typeChanged ) {
514
      removeProcessor( editor );
515
    }
516
517
    editor.rename( file );
518
    tab.ifPresent( t -> {
519
      t.setText( editor.getFilename() );
520
      t.setTooltip( createTooltip( file ) );
521
    } );
522
523
    if( typeChanged ) {
524
      updateProcessors( editor );
525
      process( editor );
526
    }
527
528
    save();
529
  }
530
531
  /**
532
   * Saves the given {@link TextResource} to a file. This is typically used
533
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
534
   *
535
   * @param resource The resource to export.
536
   */
537
  private void save( final TextResource resource ) {
538
    try {
539
      resource.save();
540
    } catch( final Exception ex ) {
541
      clue( ex );
542
      sNotifier.alert(
543
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
544
      );
545
    }
546
  }
547
548
  /**
549
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
550
   *
551
   * @return {@code true} when all editors, modified or otherwise, were
552
   * permitted to close; {@code false} when one or more editors were modified
553
   * and the user requested no closing.
554
   */
555
  public boolean closeAll() {
556
    var closable = true;
557
558
    for( final var tabPane : mTabPanes ) {
559
      final var tabIterator = tabPane.getTabs().iterator();
560
561
      while( tabIterator.hasNext() ) {
562
        final var tab = tabIterator.next();
563
        final var resource = tab.getContent();
564
565
        // The definition panes auto-save, so being specific here prevents
566
        // closing the definitions in the situation where the user wants to
567
        // continue editing (i.e., possibly save unsaved work).
568
        if( !(resource instanceof TextEditor) ) {
569
          continue;
570
        }
571
572
        if( canClose( (TextEditor) resource ) ) {
573
          tabIterator.remove();
574
          close( tab );
575
        }
576
        else {
577
          closable = false;
578
        }
579
      }
580
    }
581
582
    return closable;
583
  }
584
585
  /**
586
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
587
   * event.
588
   *
589
   * @param tab The {@link Tab} that was closed.
590
   */
591
  private void close( final Tab tab ) {
592
    assert tab != null;
593
594
    final var handler = tab.getOnClosed();
595
596
    if( handler != null ) {
597
      handler.handle( new ActionEvent() );
598
    }
599
  }
600
601
  /**
602
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
603
   */
604
  public void close() {
605
    final var editor = getTextEditor();
606
607
    if( canClose( editor ) ) {
608
      close( editor );
609
    }
610
  }
611
612
  /**
613
   * Closes the given {@link TextResource}. This must not be called from within
614
   * a loop that iterates over the tab panes using {@code forEach}, lest a
615
   * concurrent modification exception be thrown.
616
   *
617
   * @param resource The {@link TextResource} to close, without confirming with
618
   *                 the user.
619
   */
620
  private void close( final TextResource resource ) {
621
    getTab( resource ).ifPresent(
622
      tab -> {
623
        close( tab );
624
        tab.getTabPane().getTabs().remove( tab );
625
      }
626
    );
627
  }
628
629
  /**
630
   * Answers whether the given {@link TextResource} may be closed.
631
   *
632
   * @param editor The {@link TextResource} to try closing.
633
   * @return {@code true} when the editor may be closed; {@code false} when
634
   * the user has requested to keep the editor open.
635
   */
636
  private boolean canClose( final TextResource editor ) {
637
    final var editorTab = getTab( editor );
638
    final var canClose = new AtomicBoolean( true );
639
640
    if( editor.isModified() ) {
641
      final var filename = new StringBuilder();
642
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
643
644
      final var message = sNotifier.createNotification(
645
        Messages.get( "Alert.file.close.title" ),
646
        Messages.get( "Alert.file.close.text" ),
647
        filename.toString()
648
      );
649
650
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
651
652
      dialog.showAndWait().ifPresent(
653
        save -> canClose.set( save == YES ? editor.save() : save == NO )
654
      );
655
    }
656
657
    return canClose.get();
658
  }
659
660
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
661
    mTabPanes.forEach(
662
      tp -> tp.getTabs().forEach( tab -> {
663
        final var node = tab.getContent();
664
665
        if( node instanceof final TextEditor editor ) {
666
          consumer.accept( editor );
667
        }
668
      } )
669
    );
670
  }
671
672
  private ObjectProperty<TextEditor> createActiveTextEditor() {
673
    final var editor = new SimpleObjectProperty<TextEditor>();
674
675
    editor.addListener( ( c, o, n ) -> {
676
      if( n != null ) {
677
        mPreview.setBaseUri( n.getPath() );
678
        process( n );
679
      }
680
    } );
681
682
    return editor;
683
  }
684
685
  /**
686
   * Adds the HTML preview tab to its own, singular tab pane.
687
   */
688
  public void viewPreview() {
689
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
690
  }
691
692
  /**
693
   * Adds the document outline tab to its own, singular tab pane.
694
   */
695
  public void viewOutline() {
696
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
697
  }
698
699
  public void viewStatistics() {
700
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
701
  }
702
703
  public void viewFiles() {
704
    try {
705
      final var factory = new FilePickerFactory( getWorkspace() );
706
      final var fileManager = factory.createModeless();
707
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
708
    } catch( final Exception ex ) {
709
      clue( ex );
710
    }
711
  }
712
713
  private void viewTab(
714
    final Node node, final MediaType mediaType, final String key ) {
715
    final var tabPane = obtainTabPane( mediaType );
716
717
    for( final var tab : tabPane.getTabs() ) {
718
      if( tab.getContent() == node ) {
719
        return;
720
      }
721
    }
722
723
    tabPane.getTabs().add( createTab( get( key ), node ) );
724
    addTabPane( tabPane );
725
  }
726
727
  public void viewRefresh() {
728
    mPreview.refresh();
729
    Engine.clear();
730
    mRBootstrapController.update();
731
  }
732
733
  /**
734
   * Returns the tab that contains the given {@link TextEditor}.
735
   *
736
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
737
   * @return The first tab having content that matches the given tab.
738
   */
739
  private Optional<Tab> getTab( final TextResource editor ) {
740
    return mTabPanes.stream()
741
                    .flatMap( pane -> pane.getTabs().stream() )
742
                    .filter( tab -> editor.equals( tab.getContent() ) )
743
                    .findFirst();
744
  }
745
746
  /**
747
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
748
   * is used to detect when the active {@link DefinitionEditor} has changed.
749
   * Upon changing, the variables are interpolated and the active text editor
750
   * is refreshed.
751
   *
752
   * @param textEditor Text editor to update with the revised resolved map.
753
   * @return A newly configured property that represents the active
754
   * {@link DefinitionEditor}, never null.
755
   */
756
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
757
    final ObjectProperty<TextEditor> textEditor ) {
758
    final var defEditor = new SimpleObjectProperty<>(
759
      createDefinitionEditor()
760
    );
761
762
    defEditor.addListener( ( c, o, n ) -> {
763
      final var editor = textEditor.get();
764
765
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
766
        // Initialize R before the editor is added.
767
        mRBootstrapController.update();
768
      }
769
770
      process( editor );
771
    } );
772
773
    return defEditor;
774
  }
775
776
  private Tab createTab( final String filename, final Node node ) {
777
    return new DetachableTab( filename, node );
778
  }
779
780
  private Tab createTab( final File file ) {
781
    final var r = createTextResource( file );
782
    final var tab = createTab( r.getFilename(), r.getNode() );
783
784
    r.modifiedProperty().addListener(
785
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
786
    );
787
788
    // This is called when either the tab is closed by the user clicking on
789
    // the tab's close icon or when closing (all) from the file menu.
790
    tab.setOnClosed(
791
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
792
    );
793
794
    // When closing a tab, give focus to the newly revealed tab.
795
    tab.selectedProperty().addListener( ( c, o, n ) -> {
796
      if( n != null && n ) {
797
        final var pane = tab.getTabPane();
798
799
        if( pane != null ) {
800
          pane.requestFocus();
801
        }
802
      }
803
    } );
804
805
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
806
      if( nPane != null ) {
807
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
808
          if( n != null && n ) {
809
            final var selected = nPane.getSelectionModel().getSelectedItem();
810
            final var node = selected.getContent();
811
            node.requestFocus();
812
          }
813
        } );
814
      }
815
    } );
816
817
    return tab;
818
  }
819
820
  /**
821
   * Creates bins for the different {@link MediaType}s, which eventually are
822
   * added to the UI as separate tab panes. If ever a general-purpose scene
823
   * exporter is developed to serialize a scene to an FXML file, this could
824
   * be replaced by such a class.
825
   * <p>
826
   * When binning the files, this makes sure that at least one file exists
827
   * for every type. If the user has opted to close a particular type (such
828
   * as the definition pane), the view will suppressed elsewhere.
829
   * </p>
830
   * <p>
831
   * The order that the binned files are returned will be reflected in the
832
   * order that the corresponding panes are rendered in the UI.
833
   * </p>
834
   *
835
   * @param paths The file paths to bin according to their type.
836
   * @return An in-order list of files, first by structured definition files,
837
   * then by plain text documents.
838
   */
839
  private List<File> collect( final SetProperty<String> paths ) {
840
    // Treat all files destined for the text editor as plain text documents
841
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
842
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
843
    final Function<MediaType, MediaType> bin =
844
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
845
846
    // Create two groups: YAML files and plain text files. The order that
847
    // the elements are listed in the enumeration for media types determines
848
    // what files are loaded first. Variable definitions come before all other
849
    // plain text documents.
850
    final var bins = paths
851
      .stream()
852
      .collect(
853
        groupingBy(
854
          path -> bin.apply( MediaType.fromFilename( path ) ),
855
          () -> new TreeMap<>( Enum::compareTo ),
856
          Collectors.toList()
857
        )
858
      );
859
860
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
861
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
862
863
    final var result = new LinkedList<File>();
864
865
    // Ensure that the same types are listed together (keep insertion order).
866
    bins.forEach( ( mediaType, files ) -> result.addAll(
867
      files.stream().map( File::new ).toList() )
868
    );
869
870
    return result;
871
  }
872
873
  /**
874
   * Force the active editor to update, which will cause the processor
875
   * to re-evaluate the interpolated definition map thereby updating the
876
   * preview pane.
877
   *
878
   * @param editor Contains the source document to update in the preview pane.
879
   */
880
  private void process( final TextEditor editor ) {
881
    // Ensure processing does not run on the JavaFX thread, which frees the
882
    // text editor immediately for caret movement. The preview will have a
883
    // slight delay when catching up to the caret position.
884
    final var task = new Task<Void>() {
885
      @Override
886
      public Void call() {
887
        try {
888
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
889
          p.apply( editor == null ? "" : editor.getText() );
890
        } catch( final Exception ex ) {
891
          clue( ex );
892
        }
893
894
        return null;
895
      }
896
    };
897
898
    // TODO: Each time the editor successfully runs the processor the task is
899
    //   considered successful. Due to the rapid-fire nature of processing
900
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
901
    //   scroll each time.
902
    //   The algorithm:
903
    //   1. Peek at the oldest time.
904
    //   2. If the difference between the oldest time and current time exceeds
905
    //      250 milliseconds, then invoke the scrolling.
906
    //   3. Insert the current time into the circular queue.
907
    task.setOnSucceeded(
908
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
909
    );
910
911
    // Prevents multiple process requests from executing simultaneously (due
912
    // to having a restricted queue size).
913
    sExecutor.execute( task );
914
  }
915
916
  /**
917
   * Lazily creates a {@link TabPane} configured to listen for tab select
918
   * events. The tab pane is associated with a given media type so that
919
   * similar files can be grouped together.
920
   *
921
   * @param mediaType The media type to associate with the tab pane.
922
   * @return An instance of {@link TabPane} that will handle tab docking.
923
   */
924
  private TabPane obtainTabPane( final MediaType mediaType ) {
925
    for( final var pane : mTabPanes ) {
926
      for( final var tab : pane.getTabs() ) {
927
        final var node = tab.getContent();
928
929
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
930
          return pane;
931
        }
932
      }
933
    }
934
935
    final var pane = createTabPane();
936
    mTabPanes.add( pane );
937
    return pane;
938
  }
939
940
  /**
941
   * Creates an initialized {@link TabPane} instance.
942
   *
943
   * @return A new {@link TabPane} with all listeners configured.
944
   */
945
  private TabPane createTabPane() {
946
    final var tabPane = new DetachableTabPane();
947
948
    initStageOwnerFactory( tabPane );
949
    initTabListener( tabPane );
950
951
    return tabPane;
952
  }
953
954
  /**
955
   * When any {@link DetachableTabPane} is detached from the main window,
956
   * the stage owner factory must be given its parent window, which will
957
   * own the child window. The parent window is the {@link MainPane}'s
958
   * {@link Scene}'s {@link Window} instance.
959
   *
960
   * <p>
961
   * This will derives the new title from the main window title, incrementing
962
   * the window count to help uniquely identify the child windows.
963
   * </p>
964
   *
965
   * @param tabPane A new {@link DetachableTabPane} to configure.
966
   */
967
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
968
    tabPane.setStageOwnerFactory( stage -> {
969
      final var title = get(
970
        "Detach.tab.title",
971
        ((Stage) getWindow()).getTitle(), ++mWindowCount
972
      );
973
      stage.setTitle( title );
974
975
      return getScene().getWindow();
976
    } );
977
  }
978
979
  /**
980
   * Responsible for configuring the content of each {@link DetachableTab} when
981
   * it is added to the given {@link DetachableTabPane} instance.
982
   * <p>
983
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
984
   * is initialized to perform synchronized scrolling between the editor and
985
   * its preview window. Additionally, the last tab in the tab pane's list of
986
   * tabs is given focus.
987
   * </p>
988
   * <p>
989
   * Note that multiple tabs can be added simultaneously.
990
   * </p>
991
   *
992
   * @param tabPane A new {@link TabPane} to configure.
993
   */
994
  private void initTabListener( final TabPane tabPane ) {
995
    tabPane.getTabs().addListener(
996
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
997
        while( listener.next() ) {
998
          if( listener.wasAdded() ) {
999
            final var tabs = listener.getAddedSubList();
1000
1001
            tabs.forEach( tab -> {
1002
              final var node = tab.getContent();
1003
1004
              if( node instanceof TextEditor ) {
1005
                initScrollEventListener( tab );
1006
              }
1007
            } );
1008
1009
            // Select and give focus to the last tab opened.
1010
            final var index = tabs.size() - 1;
1011
            if( index >= 0 ) {
1012
              final var tab = tabs.get( index );
1013
              tabPane.getSelectionModel().select( tab );
1014
              tab.getContent().requestFocus();
1015
            }
1016
          }
1017
        }
1018
      }
1019
    );
1020
  }
1021
1022
  /**
1023
   * Synchronizes scrollbar positions between the given {@link Tab} that
1024
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1025
   *
1026
   * @param tab The container for an instance of {@link TextEditor}.
1027
   */
1028
  private void initScrollEventListener( final Tab tab ) {
1029
    final var editor = (TextEditor) tab.getContent();
1030
    final var scrollPane = editor.getScrollPane();
1031
    final var scrollBar = mPreview.getVerticalScrollBar();
1032
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1033
1034
    handler.enabledProperty().bind( tab.selectedProperty() );
1035
  }
1036
1037
  private void addTabPane( final int index, final TabPane tabPane ) {
1038
    final var items = getItems();
1039
1040
    if( !items.contains( tabPane ) ) {
1041
      items.add( index, tabPane );
1042
    }
1043
  }
1044
1045
  private void addTabPane( final TabPane tabPane ) {
1046
    addTabPane( getItems().size(), tabPane );
1047
  }
1048
1049
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1050
    final var w = getWorkspace();
1051
1052
    return builder()
1053
      .with( Mutator::setDefinitions, this::getDefinitions )
1054
      .with( Mutator::setLocale, w::getLocale )
1055
      .with( Mutator::setMetadata, w::getMetadata )
1056
      .with( Mutator::setThemesDir, w::getThemesPath )
1057
      .with( Mutator::setCachesDir,
1058
             () -> w.getFile( KEY_CACHES_DIR ) )
1059
      .with( Mutator::setImagesDir,
1060
             () -> w.getFile( KEY_IMAGES_DIR ) )
1061
      .with( Mutator::setImageOrder,
1062
             () -> w.getString( KEY_IMAGES_ORDER ) )
1063
      .with( Mutator::setImageServer,
1064
             () -> w.getString( KEY_IMAGES_SERVER ) )
1065
      .with( Mutator::setFontsDir,
1066
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
1067
      .with( Mutator::setCaret,
1068
             () -> getTextEditor().getCaret() )
1069
      .with( Mutator::setSigilBegan,
1070
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1071
      .with( Mutator::setSigilEnded,
1072
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1073
      .with( Mutator::setRScript,
1074
             () -> w.getString( KEY_R_SCRIPT ) )
1075
      .with( Mutator::setRWorkingDir,
1076
             () -> w.getFile( KEY_R_DIR ).toPath() )
1077
      .with( Mutator::setCurlQuotes,
1078
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1079
      .with( Mutator::setAutoRemove,
1080
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1081
  }
1082
1083
  public ProcessorContext createProcessorContext() {
1084
    return createProcessorContextBuilder( NONE ).build();
1085
  }
1086
1087
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder(
1088
    final ExportFormat format ) {
1089
    final var textEditor = getTextEditor();
1090
    final var sourcePath = textEditor.getPath();
1091
1092
    return processorContextBuilder()
1093
      .with( Mutator::setSourcePath, sourcePath )
1094
      .with( Mutator::setExportFormat, format );
1095
  }
1096
1097
  /**
1098
   * @param targetPath Used when exporting to a PDF file (binary).
1099
   * @param format     Used when processors export to a new text format.
1100
   * @return A new {@link ProcessorContext} to use when creating an instance of
1101
   * {@link Processor}.
1102
   */
1103
  public ProcessorContext createProcessorContext(
1104
    final Path targetPath, final ExportFormat format ) {
1105
    assert targetPath != null;
1106
    assert format != null;
1107
1108
    return createProcessorContextBuilder( format )
1109
      .with( Mutator::setTargetPath, targetPath )
1110
      .build();
1111
  }
1112
1113
  /**
1114
   * @param sourcePath Used by {@link ProcessorFactory} to determine
1115
   *                   {@link Processor} type to create based on file type.
1116
   * @return A new {@link ProcessorContext} to use when creating an instance of
1117
   * {@link Processor}.
1118
   */
1119
  private ProcessorContext createProcessorContext( final Path sourcePath ) {
1120
    return processorContextBuilder()
1121
      .with( Mutator::setSourcePath, sourcePath )
1122
      .with( Mutator::setExportFormat, NONE )
1123
      .build();
1124
  }
1125
1126
  private TextResource createTextResource( final File file ) {
1127
    // TODO: Create PlainTextEditor that's returned by default.
1128
    return MediaType.valueFrom( file ) == TEXT_YAML
1129
      ? createDefinitionEditor( file )
1130
      : createMarkdownEditor( file );
1131
  }
1132
1133
  /**
1134
   * Creates an instance of {@link MarkdownEditor} that listens for both
1135
   * caret change events and text change events. Text change events must
1136
   * take priority over caret change events because it's possible to change
1137
   * the text without moving the caret (e.g., delete selected text).
1138
   *
1139
   * @param inputFile The file containing contents for the text editor.
1140
   * @return A non-null text editor.
1141
   */
1142
  private MarkdownEditor createMarkdownEditor( final File inputFile ) {
1143
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1144
1145
    updateProcessors( editor );
1146
1147
    // Listener for editor modifications or caret position changes.
1148
    editor.addDirtyListener( ( c, o, n ) -> {
1149
      if( n ) {
1150
        // Reset the status bar after changing the text.
1151
        clue();
1152
1153
        // Processing the text may update the status bar.
1154
        process( getTextEditor() );
1155
1156
        // Update the caret position in the status bar.
1157
        CaretMovedEvent.fire( editor.getCaret() );
1158
      }
1159
    } );
1160
1161
    editor.addEventListener(
1162
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1163
    );
1164
1165
    editor.addEventListener(
1166
      keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor )
1167
    );
1168
1169
    final var textArea = editor.getTextArea();
1170
1171
    // Spell check when the paragraph changes.
1172
    textArea
1173
      .plainTextChanges()
1174
      .filter( p -> !p.isIdentity() )
1175
      .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
1176
1177
    // Store the caret position to restore it after restarting the application.
1178
    textArea.caretPositionProperty().addListener(
1179
      ( c, o, n ) ->
1180
        getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
1181
    );
1182
1183
    // Set the active editor, which refreshes the preview panel.
1184
    mTextEditor.set( editor );
1185
1186
    // Check the entire document after the spellchecker is initialized (with
1187
    // a valid lexicon) so that only the current paragraph need be scanned
1188
    // while editing. (Technically, only the most recently modified word must
1189
    // be scanned.)
1190
    mSpellChecker.addListener(
1191
      ( c, o, n ) -> runLater(
1192
        () -> iterateEditors( mEditorSpeller::checkDocument )
1193
      )
1194
    );
1195
1196
    // Check the entire document after it has been loaded.
1197
    mEditorSpeller.checkDocument( mTextEditor.get() );
1198
1199
    return editor;
1200
  }
1201
1202
  /**
1203
   * Creates a processor for an editor, provided one doesn't already exist.
1204
   *
1205
   * @param editor The editor that potentially requires an associated processor.
1206
   */
1207
  private void updateProcessors( final TextEditor editor ) {
1208
    final var path = editor.getFile().toPath();
1209
1210
    mProcessors.computeIfAbsent(
1211
      editor, p -> createProcessors(
1212
        createProcessorContext( path ),
1213
        createHtmlPreviewProcessor()
1214
      )
1215
    );
1216
  }
1217
1218
  /**
1219
   * Removes a processor for an editor. This is required because a file may
1220
   * change type while editing (e.g., from plain Markdown to R Markdown).
1221
   * In the case that an editor's type changes, its associated processor must
1222
   * be changed accordingly.
1223
   *
1224
   * @param editor The editor that potentially requires an associated processor.
1225
   */
1226
  private void removeProcessor( final TextEditor editor ) {
1227
    mProcessors.remove( editor );
1228
  }
1229
1230
  /**
1231
   * Creates a {@link Processor} capable of rendering an HTML document onto
1232
   * a GUI widget.
1233
   *
1234
   * @return The {@link Processor} for rendering an HTML document.
1235
   */
1236
  private Processor<String> createHtmlPreviewProcessor() {
1237
    return new HtmlPreviewProcessor( getPreview() );
1238
  }
1239
1240
  /**
1241
   * Creates a spellchecker that accepts all words as correct. This allows
1242
   * the spellchecker property to be initialized to a known valid value.
1243
   *
1244
   * @return A wrapped {@link PermissiveSpeller}.
1245
   */
1246
  private ObjectProperty<SpellChecker> createSpellChecker() {
1247
    return new SimpleObjectProperty<>( new PermissiveSpeller() );
1248
  }
1249
1250
  private TextEditorSpellChecker createTextEditorSpellChecker(
1251
    final ObjectProperty<SpellChecker> spellChecker ) {
1252
    return new TextEditorSpellChecker( spellChecker );
1253
  }
1254
1255
  /**
1256
   * Delegates to {@link #autoinsert()}.
1257
   *
1258
   * @param keyEvent Ignored.
1259
   */
1260
  private void autoinsert( final KeyEvent keyEvent ) {
1261
    autoinsert();
1262
  }
1263
1264
  /**
1265
   * Finds a node that matches the word at the caret, then inserts the
1266
   * corresponding definition. The definition token delimiters depend on
1267
   * the type of file being edited.
1268
   */
1269
  public void autoinsert() {
1270
    mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() );
1271
  }
1272
1273
  private TextDefinition createDefinitionEditor() {
1274
    return createDefinitionEditor( DEFINITION_DEFAULT );
1275
  }
1276
1277
  private TextDefinition createDefinitionEditor( final File file ) {
1278
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
1279
1280
    editor.addTreeChangeHandler( mTreeHandler );
1281
1282
    return editor;
1283
  }
1284
1285
  private TreeTransformer createTreeTransformer() {
1286
    return new YamlTreeTransformer();
1287
  }
1288
1289
  private Tooltip createTooltip( final File file ) {
1290
    final var path = file.toPath();
1291
    final var tooltip = new Tooltip( path.toString() );
1292
1293
    tooltip.setShowDelay( millis( 200 ) );
1294
1295
    return tooltip;
1296
  }
1297
1298
  public HtmlPreview getPreview() {
1299
    return mPreview;
1300
  }
1301
1302
  /**
1303
   * Returns the active text editor.
1304
   *
1305
   * @return The text editor that currently has focus.
1306
   */
1307
  public TextEditor getTextEditor() {
1308
    return mTextEditor.get();
1309
  }
1310
1311
  /**
1312
   * Returns the active text editor property.
1313
   *
1314
   * @return The property container for the active text editor.
1315
   */
1316
  public ReadOnlyObjectProperty<TextEditor> textEditorProperty() {
1317
    return mTextEditor;
1318
  }
1319
1320
  /**
1321
   * Returns the active text definition editor.
1322
   *
1323
   * @return The property container for the active definition editor.
1324
   */
1325
  public TextDefinition getTextDefinition() {
1326
    return mDefinitionEditor.get();
1327
  }
1328
1329
  /**
1330
   * Returns the active variable definitions, without any interpolation.
1331
   * Interpolation is a responsibility of {@link Processor} instances.
1332
   *
1333
   * @return The key-value pairs, not interpolated.
1334
   */
1335
  private Map<String, String> getDefinitions() {
1336
    return getTextDefinition().getDefinitions();
37
import javafx.beans.property.*;
38
import javafx.collections.ListChangeListener;
39
import javafx.concurrent.Task;
40
import javafx.event.ActionEvent;
41
import javafx.event.Event;
42
import javafx.event.EventHandler;
43
import javafx.scene.Node;
44
import javafx.scene.Scene;
45
import javafx.scene.control.SplitPane;
46
import javafx.scene.control.Tab;
47
import javafx.scene.control.TabPane;
48
import javafx.scene.control.Tooltip;
49
import javafx.scene.control.TreeItem.TreeModificationEvent;
50
import javafx.scene.input.KeyEvent;
51
import javafx.stage.Stage;
52
import javafx.stage.Window;
53
import org.greenrobot.eventbus.Subscribe;
54
55
import java.io.File;
56
import java.io.FileNotFoundException;
57
import java.nio.file.Path;
58
import java.util.*;
59
import java.util.concurrent.ExecutorService;
60
import java.util.concurrent.ScheduledExecutorService;
61
import java.util.concurrent.ScheduledFuture;
62
import java.util.concurrent.atomic.AtomicBoolean;
63
import java.util.concurrent.atomic.AtomicReference;
64
import java.util.function.Consumer;
65
import java.util.function.Function;
66
import java.util.stream.Collectors;
67
68
import static com.keenwrite.ExportFormat.NONE;
69
import static com.keenwrite.Launcher.terminate;
70
import static com.keenwrite.Messages.get;
71
import static com.keenwrite.constants.Constants.*;
72
import static com.keenwrite.events.Bus.register;
73
import static com.keenwrite.events.StatusEvent.clue;
74
import static com.keenwrite.io.MediaType.*;
75
import static com.keenwrite.io.MediaType.TypeName.TEXT;
76
import static com.keenwrite.preferences.AppKeys.*;
77
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
78
import static com.keenwrite.processors.ProcessorContext.Mutator;
79
import static com.keenwrite.processors.ProcessorContext.builder;
80
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
81
import static java.awt.Desktop.getDesktop;
82
import static java.util.concurrent.Executors.newFixedThreadPool;
83
import static java.util.concurrent.Executors.newScheduledThreadPool;
84
import static java.util.concurrent.TimeUnit.SECONDS;
85
import static java.util.stream.Collectors.groupingBy;
86
import static javafx.application.Platform.exit;
87
import static javafx.application.Platform.runLater;
88
import static javafx.scene.control.ButtonType.NO;
89
import static javafx.scene.control.ButtonType.YES;
90
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
91
import static javafx.scene.input.KeyCode.ENTER;
92
import static javafx.scene.input.KeyCode.SPACE;
93
import static javafx.scene.input.KeyCombination.ALT_DOWN;
94
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
95
import static javafx.util.Duration.millis;
96
import static javax.swing.SwingUtilities.invokeLater;
97
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
98
99
/**
100
 * Responsible for wiring together the main application components for a
101
 * particular {@link Workspace} (project). These include the definition views,
102
 * text editors, and preview pane along with any corresponding controllers.
103
 */
104
public final class MainPane extends SplitPane {
105
106
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
107
  private static final Notifier sNotifier = Services.load( Notifier.class );
108
109
  /**
110
   * Used when opening files to determine how each file should be binned and
111
   * therefore what tab pane to be opened within.
112
   */
113
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
114
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
115
  );
116
117
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
118
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
119
    new AtomicReference<>();
120
121
  /**
122
   * Prevents re-instantiation of processing classes.
123
   */
124
  private final Map<TextResource, Processor<String>> mProcessors =
125
    new HashMap<>();
126
127
  private final Workspace mWorkspace;
128
129
  /**
130
   * Groups similar file type tabs together.
131
   */
132
  private final List<TabPane> mTabPanes = new ArrayList<>();
133
134
  /**
135
   * Renders the actively selected plain text editor tab.
136
   */
137
  private final HtmlPreview mPreview;
138
139
  /**
140
   * Provides an interactive document outline.
141
   */
142
  private final DocumentOutline mOutline = new DocumentOutline();
143
144
  /**
145
   * Changing the active editor fires the value changed event. This allows
146
   * refreshes to happen when external definitions are modified and need to
147
   * trigger the processing chain.
148
   */
149
  private final ObjectProperty<TextEditor> mTextEditor =
150
    createActiveTextEditor();
151
152
  /**
153
   * Changing the active definition editor fires the value changed event. This
154
   * allows refreshes to happen when external definitions are modified and need
155
   * to trigger the processing chain.
156
   */
157
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
158
159
  private final ObjectProperty<SpellChecker> mSpellChecker;
160
161
  private final TextEditorSpellChecker mEditorSpeller;
162
163
  /**
164
   * Called when the definition data is changed.
165
   */
166
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
167
    event -> {
168
      process( getTextEditor() );
169
      save( getTextDefinition() );
170
    };
171
172
  /**
173
   * Tracks the number of detached tab panels opened into their own windows,
174
   * which allows unique identification of subordinate windows by their title.
175
   * It is doubtful more than 128 windows, much less 256, will be created.
176
   */
177
  private byte mWindowCount;
178
179
  private final VariableNameInjector mVariableNameInjector;
180
181
  private final RBootstrapController mRBootstrapController;
182
183
  private final DocumentStatistics mStatistics;
184
185
  @SuppressWarnings( {"FieldCanBeLocal", "unused"} )
186
  private final TypesetterInstaller mInstallWizard;
187
188
  /**
189
   * Adds all content panels to the main user interface. This will load the
190
   * configuration settings from the workspace to reproduce the settings from
191
   * a previous session.
192
   */
193
  public MainPane( final Workspace workspace ) {
194
    mWorkspace = workspace;
195
    mSpellChecker = createSpellChecker();
196
    mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
197
    mPreview = new HtmlPreview( workspace );
198
    mStatistics = new DocumentStatistics( workspace );
199
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
200
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
201
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
202
    mRBootstrapController = new RBootstrapController(
203
      mWorkspace, this::getDefinitions );
204
205
    open( collect( getRecentFiles() ) );
206
    viewPreview();
207
    setDividerPositions( calculateDividerPositions() );
208
209
    // Once the main scene's window regains focus, update the active definition
210
    // editor to the currently selected tab.
211
    runLater( () -> getWindow().setOnCloseRequest( event -> {
212
      // Order matters: Open file names must be persisted before closing all.
213
      mWorkspace.save();
214
215
      if( closeAll() ) {
216
        exit();
217
        terminate( 0 );
218
      }
219
220
      event.consume();
221
    } ) );
222
223
    register( this );
224
    initAutosave( workspace );
225
226
    restoreSession();
227
    runLater( this::restoreFocus );
228
229
    mInstallWizard = new TypesetterInstaller( workspace );
230
  }
231
232
  /**
233
   * Called when spellchecking can be run. This will reload the dictionary
234
   * into memory once, and then re-use it for all the existing text editors.
235
   *
236
   * @param event The event to process, having a populated word-frequency map.
237
   */
238
  @Subscribe
239
  public void handle( final LexiconLoadedEvent event ) {
240
    final var lexicon = event.getLexicon();
241
242
    try {
243
      final var checker = SymSpellSpeller.forLexicon( lexicon );
244
      mSpellChecker.set( checker );
245
    } catch( final Exception ex ) {
246
      clue( ex );
247
    }
248
  }
249
250
  @Subscribe
251
  public void handle( final TextEditorFocusEvent event ) {
252
    mTextEditor.set( event.get() );
253
  }
254
255
  @Subscribe
256
  public void handle( final TextDefinitionFocusEvent event ) {
257
    mDefinitionEditor.set( event.get() );
258
  }
259
260
  /**
261
   * Typically called when a file name is clicked in the preview panel.
262
   *
263
   * @param event The event to process, must contain a valid file reference.
264
   */
265
  @Subscribe
266
  public void handle( final FileOpenEvent event ) {
267
    final File eventFile;
268
    final var eventUri = event.getUri();
269
270
    if( eventUri.isAbsolute() ) {
271
      eventFile = new File( eventUri.getPath() );
272
    }
273
    else {
274
      final var activeFile = getTextEditor().getFile();
275
      final var parent = activeFile.getParentFile();
276
277
      if( parent == null ) {
278
        clue( new FileNotFoundException( eventUri.getPath() ) );
279
        return;
280
      }
281
      else {
282
        final var parentPath = parent.getAbsolutePath();
283
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
284
      }
285
    }
286
287
    final var mediaType = MediaTypeExtension.fromFile( eventFile );
288
289
    runLater( () -> {
290
      // Open text files locally.
291
      if( mediaType.isType( TEXT ) ) {
292
        open( eventFile );
293
      }
294
      else {
295
        try {
296
          // Delegate opening all other file types to the operating system.
297
          getDesktop().open( eventFile );
298
        } catch( final Exception ex ) {
299
          clue( ex );
300
        }
301
      }
302
    } );
303
  }
304
305
  @Subscribe
306
  public void handle( final CaretNavigationEvent event ) {
307
    runLater( () -> {
308
      final var textArea = getTextEditor();
309
      textArea.moveTo( event.getOffset() );
310
      textArea.requestFocus();
311
    } );
312
  }
313
314
  @Subscribe
315
  public void handle( final InsertDefinitionEvent<String> event ) {
316
    final var leaf = event.getLeaf();
317
    final var editor = mTextEditor.get();
318
319
    mVariableNameInjector.insert( editor, leaf );
320
  }
321
322
  private void initAutosave( final Workspace workspace ) {
323
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
324
325
    rate.addListener(
326
      ( c, o, n ) -> {
327
        final var taskRef = mSaveTask.get();
328
329
        // Prevent multiple autosaves from running.
330
        if( taskRef != null ) {
331
          taskRef.cancel( false );
332
        }
333
334
        initAutosave( rate );
335
      }
336
    );
337
338
    // Start the save listener (avoids duplicating some code).
339
    initAutosave( rate );
340
  }
341
342
  private void initAutosave( final IntegerProperty rate ) {
343
    mSaveTask.set(
344
      mSaver.scheduleAtFixedRate(
345
        () -> {
346
          if( getTextEditor().isModified() ) {
347
            // Ensure the modified indicator is cleared by running on EDT.
348
            runLater( this::save );
349
          }
350
        }, 0, rate.intValue(), SECONDS
351
      )
352
    );
353
  }
354
355
  /**
356
   * TODO: Load divider positions from exported settings, see
357
   *   {@link #collect(SetProperty)} comment.
358
   */
359
  private double[] calculateDividerPositions() {
360
    final var ratio = 100f / getItems().size() / 100;
361
    final var positions = getDividerPositions();
362
363
    for( int i = 0; i < positions.length; i++ ) {
364
      positions[ i ] = ratio * i;
365
    }
366
367
    return positions;
368
  }
369
370
  /**
371
   * Opens all the files into the application, provided the paths are unique.
372
   * This may only be called for any type of files that a user can edit
373
   * (i.e., update and persist), such as definitions and text files.
374
   *
375
   * @param files The list of files to open.
376
   */
377
  public void open( final List<File> files ) {
378
    files.forEach( this::open );
379
  }
380
381
  /**
382
   * This opens the given file. Since the preview pane is not a file that
383
   * can be opened, it is safe to add a listener to the detachable pane.
384
   * This will exit early if the given file is not a regular file (i.e., a
385
   * directory).
386
   *
387
   * @param inputFile The file to open.
388
   */
389
  private void open( final File inputFile ) {
390
    // Prevent opening directories (a non-existent "untitled.md" is fine).
391
    if( !inputFile.isFile() && inputFile.exists() ) {
392
      return;
393
    }
394
395
    final var tab = createTab( inputFile );
396
    final var node = tab.getContent();
397
    final var mediaType = MediaType.valueFrom( inputFile );
398
    final var tabPane = obtainTabPane( mediaType );
399
400
    tab.setTooltip( createTooltip( inputFile ) );
401
    tabPane.setFocusTraversable( false );
402
    tabPane.setTabClosingPolicy( ALL_TABS );
403
    tabPane.getTabs().add( tab );
404
405
    // Attach the tab scene factory for new tab panes.
406
    if( !getItems().contains( tabPane ) ) {
407
      addTabPane(
408
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
409
      );
410
    }
411
412
    if( inputFile.isFile() ) {
413
      getRecentFiles().add( inputFile.getAbsolutePath() );
414
    }
415
  }
416
417
  /**
418
   * Gives focus to the most recently edited document and attempts to move
419
   * the caret to the most recently known offset into said document.
420
   */
421
  private void restoreSession() {
422
    final var workspace = getWorkspace();
423
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
424
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
425
426
    for( final var pane : mTabPanes ) {
427
      for( final var tab : pane.getTabs() ) {
428
        final var tooltip = tab.getTooltip();
429
430
        if( tooltip != null ) {
431
          final var tabName = tooltip.getText();
432
          final var fileName = file.getValue().toString();
433
434
          if( tabName.equalsIgnoreCase( fileName ) ) {
435
            final var node = tab.getContent();
436
437
            pane.getSelectionModel().select( tab );
438
            node.requestFocus();
439
440
            if( node instanceof TextEditor editor ) {
441
              runLater( () -> editor.moveTo( offset.getValue() ) );
442
            }
443
444
            break;
445
          }
446
        }
447
      }
448
    }
449
  }
450
451
  /**
452
   * Sets the focus to the middle pane, which contains the text editor tabs.
453
   */
454
  private void restoreFocus() {
455
    // Work around a bug where focusing directly on the middle pane results
456
    // in the R engine not loading variables properly.
457
    mTabPanes.get( 0 ).requestFocus();
458
459
    // This is the only line that should be required.
460
    mTabPanes.get( 1 ).requestFocus();
461
  }
462
463
  /**
464
   * Opens a new text editor document using the default document file name.
465
   */
466
  public void newTextEditor() {
467
    open( DOCUMENT_DEFAULT );
468
  }
469
470
  /**
471
   * Opens a new definition editor document using the default definition
472
   * file name.
473
   */
474
  @SuppressWarnings( "unused" )
475
  public void newDefinitionEditor() {
476
    open( DEFINITION_DEFAULT );
477
  }
478
479
  /**
480
   * Iterates over all tab panes to find all {@link TextEditor}s and request
481
   * that they save themselves.
482
   */
483
  public void saveAll() {
484
    iterateEditors( this::save );
485
  }
486
487
  /**
488
   * Requests that the active {@link TextEditor} saves itself. Don't bother
489
   * checking if modified first because if the user swaps external media from
490
   * an external source (e.g., USB thumb drive), save should not second-guess
491
   * the user: save always re-saves. Also, it's less code.
492
   */
493
  public void save() {
494
    save( getTextEditor() );
495
  }
496
497
  /**
498
   * Saves the active {@link TextEditor} under a new name.
499
   *
500
   * @param files The new active editor {@link File} reference, must contain
501
   *              at least one element.
502
   */
503
  public void saveAs( final List<File> files ) {
504
    assert files != null;
505
    assert !files.isEmpty();
506
    final var editor = getTextEditor();
507
    final var tab = getTab( editor );
508
    final var file = files.get( 0 );
509
510
    // If the file type has changed, refresh the processors.
511
    final var mediaType = MediaType.valueFrom( file );
512
    final var typeChanged = !editor.isMediaType( mediaType );
513
514
    if( typeChanged ) {
515
      removeProcessor( editor );
516
    }
517
518
    editor.rename( file );
519
    tab.ifPresent( t -> {
520
      t.setText( editor.getFilename() );
521
      t.setTooltip( createTooltip( file ) );
522
    } );
523
524
    if( typeChanged ) {
525
      updateProcessors( editor );
526
      process( editor );
527
    }
528
529
    save();
530
  }
531
532
  /**
533
   * Saves the given {@link TextResource} to a file. This is typically used
534
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
535
   *
536
   * @param resource The resource to export.
537
   */
538
  private void save( final TextResource resource ) {
539
    try {
540
      resource.save();
541
    } catch( final Exception ex ) {
542
      clue( ex );
543
      sNotifier.alert(
544
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
545
      );
546
    }
547
  }
548
549
  /**
550
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
551
   *
552
   * @return {@code true} when all editors, modified or otherwise, were
553
   * permitted to close; {@code false} when one or more editors were modified
554
   * and the user requested no closing.
555
   */
556
  public boolean closeAll() {
557
    var closable = true;
558
559
    for( final var tabPane : mTabPanes ) {
560
      final var tabIterator = tabPane.getTabs().iterator();
561
562
      while( tabIterator.hasNext() ) {
563
        final var tab = tabIterator.next();
564
        final var resource = tab.getContent();
565
566
        // The definition panes auto-save, so being specific here prevents
567
        // closing the definitions in the situation where the user wants to
568
        // continue editing (i.e., possibly save unsaved work).
569
        if( !(resource instanceof TextEditor) ) {
570
          continue;
571
        }
572
573
        if( canClose( (TextEditor) resource ) ) {
574
          tabIterator.remove();
575
          close( tab );
576
        }
577
        else {
578
          closable = false;
579
        }
580
      }
581
    }
582
583
    return closable;
584
  }
585
586
  /**
587
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
588
   * event.
589
   *
590
   * @param tab The {@link Tab} that was closed.
591
   */
592
  private void close( final Tab tab ) {
593
    assert tab != null;
594
595
    final var handler = tab.getOnClosed();
596
597
    if( handler != null ) {
598
      handler.handle( new ActionEvent() );
599
    }
600
  }
601
602
  /**
603
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
604
   */
605
  public void close() {
606
    final var editor = getTextEditor();
607
608
    if( canClose( editor ) ) {
609
      close( editor );
610
    }
611
  }
612
613
  /**
614
   * Closes the given {@link TextResource}. This must not be called from within
615
   * a loop that iterates over the tab panes using {@code forEach}, lest a
616
   * concurrent modification exception be thrown.
617
   *
618
   * @param resource The {@link TextResource} to close, without confirming with
619
   *                 the user.
620
   */
621
  private void close( final TextResource resource ) {
622
    getTab( resource ).ifPresent(
623
      tab -> {
624
        close( tab );
625
        tab.getTabPane().getTabs().remove( tab );
626
      }
627
    );
628
  }
629
630
  /**
631
   * Answers whether the given {@link TextResource} may be closed.
632
   *
633
   * @param editor The {@link TextResource} to try closing.
634
   * @return {@code true} when the editor may be closed; {@code false} when
635
   * the user has requested to keep the editor open.
636
   */
637
  private boolean canClose( final TextResource editor ) {
638
    final var editorTab = getTab( editor );
639
    final var canClose = new AtomicBoolean( true );
640
641
    if( editor.isModified() ) {
642
      final var filename = new StringBuilder();
643
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
644
645
      final var message = sNotifier.createNotification(
646
        Messages.get( "Alert.file.close.title" ),
647
        Messages.get( "Alert.file.close.text" ),
648
        filename.toString()
649
      );
650
651
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
652
653
      dialog.showAndWait().ifPresent(
654
        save -> canClose.set( save == YES ? editor.save() : save == NO )
655
      );
656
    }
657
658
    return canClose.get();
659
  }
660
661
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
662
    mTabPanes.forEach(
663
      tp -> tp.getTabs().forEach( tab -> {
664
        final var node = tab.getContent();
665
666
        if( node instanceof final TextEditor editor ) {
667
          consumer.accept( editor );
668
        }
669
      } )
670
    );
671
  }
672
673
  private ObjectProperty<TextEditor> createActiveTextEditor() {
674
    final var editor = new SimpleObjectProperty<TextEditor>();
675
676
    editor.addListener( ( c, o, n ) -> {
677
      if( n != null ) {
678
        mPreview.setBaseUri( n.getPath() );
679
        process( n );
680
      }
681
    } );
682
683
    return editor;
684
  }
685
686
  /**
687
   * Adds the HTML preview tab to its own, singular tab pane.
688
   */
689
  public void viewPreview() {
690
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
691
  }
692
693
  /**
694
   * Adds the document outline tab to its own, singular tab pane.
695
   */
696
  public void viewOutline() {
697
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
698
  }
699
700
  public void viewStatistics() {
701
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
702
  }
703
704
  public void viewFiles() {
705
    try {
706
      final var factory = new FilePickerFactory( getWorkspace() );
707
      final var fileManager = factory.createModeless();
708
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
709
    } catch( final Exception ex ) {
710
      clue( ex );
711
    }
712
  }
713
714
  private void viewTab(
715
    final Node node, final MediaType mediaType, final String key ) {
716
    final var tabPane = obtainTabPane( mediaType );
717
718
    for( final var tab : tabPane.getTabs() ) {
719
      if( tab.getContent() == node ) {
720
        return;
721
      }
722
    }
723
724
    tabPane.getTabs().add( createTab( get( key ), node ) );
725
    addTabPane( tabPane );
726
  }
727
728
  public void viewRefresh() {
729
    mPreview.refresh();
730
    Engine.clear();
731
    mRBootstrapController.update();
732
  }
733
734
  /**
735
   * Returns the tab that contains the given {@link TextEditor}.
736
   *
737
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
738
   * @return The first tab having content that matches the given tab.
739
   */
740
  private Optional<Tab> getTab( final TextResource editor ) {
741
    return mTabPanes.stream()
742
                    .flatMap( pane -> pane.getTabs().stream() )
743
                    .filter( tab -> editor.equals( tab.getContent() ) )
744
                    .findFirst();
745
  }
746
747
  /**
748
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
749
   * is used to detect when the active {@link DefinitionEditor} has changed.
750
   * Upon changing, the variables are interpolated and the active text editor
751
   * is refreshed.
752
   *
753
   * @param textEditor Text editor to update with the revised resolved map.
754
   * @return A newly configured property that represents the active
755
   * {@link DefinitionEditor}, never null.
756
   */
757
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
758
    final ObjectProperty<TextEditor> textEditor ) {
759
    final var defEditor = new SimpleObjectProperty<>(
760
      createDefinitionEditor()
761
    );
762
763
    defEditor.addListener( ( c, o, n ) -> {
764
      final var editor = textEditor.get();
765
766
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
767
        // Initialize R before the editor is added.
768
        mRBootstrapController.update();
769
      }
770
771
      process( editor );
772
    } );
773
774
    return defEditor;
775
  }
776
777
  private Tab createTab( final String filename, final Node node ) {
778
    return new DetachableTab( filename, node );
779
  }
780
781
  private Tab createTab( final File file ) {
782
    final var r = createTextResource( file );
783
    final var tab = createTab( r.getFilename(), r.getNode() );
784
785
    r.modifiedProperty().addListener(
786
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
787
    );
788
789
    // This is called when either the tab is closed by the user clicking on
790
    // the tab's close icon or when closing (all) from the file menu.
791
    tab.setOnClosed(
792
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
793
    );
794
795
    // When closing a tab, give focus to the newly revealed tab.
796
    tab.selectedProperty().addListener( ( c, o, n ) -> {
797
      if( n != null && n ) {
798
        final var pane = tab.getTabPane();
799
800
        if( pane != null ) {
801
          pane.requestFocus();
802
        }
803
      }
804
    } );
805
806
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
807
      if( nPane != null ) {
808
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
809
          if( n != null && n ) {
810
            final var selected = nPane.getSelectionModel().getSelectedItem();
811
            final var node = selected.getContent();
812
            node.requestFocus();
813
          }
814
        } );
815
      }
816
    } );
817
818
    return tab;
819
  }
820
821
  /**
822
   * Creates bins for the different {@link MediaType}s, which eventually are
823
   * added to the UI as separate tab panes. If ever a general-purpose scene
824
   * exporter is developed to serialize a scene to an FXML file, this could
825
   * be replaced by such a class.
826
   * <p>
827
   * When binning the files, this makes sure that at least one file exists
828
   * for every type. If the user has opted to close a particular type (such
829
   * as the definition pane), the view will suppressed elsewhere.
830
   * </p>
831
   * <p>
832
   * The order that the binned files are returned will be reflected in the
833
   * order that the corresponding panes are rendered in the UI.
834
   * </p>
835
   *
836
   * @param paths The file paths to bin according to their type.
837
   * @return An in-order list of files, first by structured definition files,
838
   * then by plain text documents.
839
   */
840
  private List<File> collect( final SetProperty<String> paths ) {
841
    // Treat all files destined for the text editor as plain text documents
842
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
843
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
844
    final Function<MediaType, MediaType> bin =
845
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
846
847
    // Create two groups: YAML files and plain text files. The order that
848
    // the elements are listed in the enumeration for media types determines
849
    // what files are loaded first. Variable definitions come before all other
850
    // plain text documents.
851
    final var bins = paths
852
      .stream()
853
      .collect(
854
        groupingBy(
855
          path -> bin.apply( MediaType.fromFilename( path ) ),
856
          () -> new TreeMap<>( Enum::compareTo ),
857
          Collectors.toList()
858
        )
859
      );
860
861
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
862
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
863
864
    final var result = new LinkedList<File>();
865
866
    // Ensure that the same types are listed together (keep insertion order).
867
    bins.forEach( ( mediaType, files ) -> result.addAll(
868
      files.stream().map( File::new ).toList() )
869
    );
870
871
    return result;
872
  }
873
874
  /**
875
   * Force the active editor to update, which will cause the processor
876
   * to re-evaluate the interpolated definition map thereby updating the
877
   * preview pane.
878
   *
879
   * @param editor Contains the source document to update in the preview pane.
880
   */
881
  private void process( final TextEditor editor ) {
882
    // Ensure processing does not run on the JavaFX thread, which frees the
883
    // text editor immediately for caret movement. The preview will have a
884
    // slight delay when catching up to the caret position.
885
    final var task = new Task<Void>() {
886
      @Override
887
      public Void call() {
888
        try {
889
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
890
          p.apply( editor == null ? "" : editor.getText() );
891
        } catch( final Exception ex ) {
892
          clue( ex );
893
        }
894
895
        return null;
896
      }
897
    };
898
899
    // TODO: Each time the editor successfully runs the processor the task is
900
    //   considered successful. Due to the rapid-fire nature of processing
901
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
902
    //   scroll each time.
903
    //   The algorithm:
904
    //   1. Peek at the oldest time.
905
    //   2. If the difference between the oldest time and current time exceeds
906
    //      250 milliseconds, then invoke the scrolling.
907
    //   3. Insert the current time into the circular queue.
908
    task.setOnSucceeded(
909
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
910
    );
911
912
    // Prevents multiple process requests from executing simultaneously (due
913
    // to having a restricted queue size).
914
    sExecutor.execute( task );
915
  }
916
917
  /**
918
   * Lazily creates a {@link TabPane} configured to listen for tab select
919
   * events. The tab pane is associated with a given media type so that
920
   * similar files can be grouped together.
921
   *
922
   * @param mediaType The media type to associate with the tab pane.
923
   * @return An instance of {@link TabPane} that will handle tab docking.
924
   */
925
  private TabPane obtainTabPane( final MediaType mediaType ) {
926
    for( final var pane : mTabPanes ) {
927
      for( final var tab : pane.getTabs() ) {
928
        final var node = tab.getContent();
929
930
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
931
          return pane;
932
        }
933
      }
934
    }
935
936
    final var pane = createTabPane();
937
    mTabPanes.add( pane );
938
    return pane;
939
  }
940
941
  /**
942
   * Creates an initialized {@link TabPane} instance.
943
   *
944
   * @return A new {@link TabPane} with all listeners configured.
945
   */
946
  private TabPane createTabPane() {
947
    final var tabPane = new DetachableTabPane();
948
949
    initStageOwnerFactory( tabPane );
950
    initTabListener( tabPane );
951
952
    return tabPane;
953
  }
954
955
  /**
956
   * When any {@link DetachableTabPane} is detached from the main window,
957
   * the stage owner factory must be given its parent window, which will
958
   * own the child window. The parent window is the {@link MainPane}'s
959
   * {@link Scene}'s {@link Window} instance.
960
   *
961
   * <p>
962
   * This will derives the new title from the main window title, incrementing
963
   * the window count to help uniquely identify the child windows.
964
   * </p>
965
   *
966
   * @param tabPane A new {@link DetachableTabPane} to configure.
967
   */
968
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
969
    tabPane.setStageOwnerFactory( stage -> {
970
      final var title = get(
971
        "Detach.tab.title",
972
        ((Stage) getWindow()).getTitle(), ++mWindowCount
973
      );
974
      stage.setTitle( title );
975
976
      return getScene().getWindow();
977
    } );
978
  }
979
980
  /**
981
   * Responsible for configuring the content of each {@link DetachableTab} when
982
   * it is added to the given {@link DetachableTabPane} instance.
983
   * <p>
984
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
985
   * is initialized to perform synchronized scrolling between the editor and
986
   * its preview window. Additionally, the last tab in the tab pane's list of
987
   * tabs is given focus.
988
   * </p>
989
   * <p>
990
   * Note that multiple tabs can be added simultaneously.
991
   * </p>
992
   *
993
   * @param tabPane A new {@link TabPane} to configure.
994
   */
995
  private void initTabListener( final TabPane tabPane ) {
996
    tabPane.getTabs().addListener(
997
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
998
        while( listener.next() ) {
999
          if( listener.wasAdded() ) {
1000
            final var tabs = listener.getAddedSubList();
1001
1002
            tabs.forEach( tab -> {
1003
              final var node = tab.getContent();
1004
1005
              if( node instanceof TextEditor ) {
1006
                initScrollEventListener( tab );
1007
              }
1008
            } );
1009
1010
            // Select and give focus to the last tab opened.
1011
            final var index = tabs.size() - 1;
1012
            if( index >= 0 ) {
1013
              final var tab = tabs.get( index );
1014
              tabPane.getSelectionModel().select( tab );
1015
              tab.getContent().requestFocus();
1016
            }
1017
          }
1018
        }
1019
      }
1020
    );
1021
  }
1022
1023
  /**
1024
   * Synchronizes scrollbar positions between the given {@link Tab} that
1025
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1026
   *
1027
   * @param tab The container for an instance of {@link TextEditor}.
1028
   */
1029
  private void initScrollEventListener( final Tab tab ) {
1030
    final var editor = (TextEditor) tab.getContent();
1031
    final var scrollPane = editor.getScrollPane();
1032
    final var scrollBar = mPreview.getVerticalScrollBar();
1033
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1034
1035
    handler.enabledProperty().bind( tab.selectedProperty() );
1036
  }
1037
1038
  private void addTabPane( final int index, final TabPane tabPane ) {
1039
    final var items = getItems();
1040
1041
    if( !items.contains( tabPane ) ) {
1042
      items.add( index, tabPane );
1043
    }
1044
  }
1045
1046
  private void addTabPane( final TabPane tabPane ) {
1047
    addTabPane( getItems().size(), tabPane );
1048
  }
1049
1050
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1051
    final var w = getWorkspace();
1052
1053
    return builder()
1054
      .with( Mutator::setDefinitions, this::getDefinitions )
1055
      .with( Mutator::setLocale, w::getLocale )
1056
      .with( Mutator::setMetadata, w::getMetadata )
1057
      .with( Mutator::setThemesDir, w::getThemesPath )
1058
      .with( Mutator::setCachesDir,
1059
             () -> w.getFile( KEY_CACHES_DIR ) )
1060
      .with( Mutator::setImagesDir,
1061
             () -> w.getFile( KEY_IMAGES_DIR ) )
1062
      .with( Mutator::setImageOrder,
1063
             () -> w.getString( KEY_IMAGES_ORDER ) )
1064
      .with( Mutator::setImageServer,
1065
             () -> w.getString( KEY_IMAGES_SERVER ) )
1066
      .with( Mutator::setFontsDir,
1067
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
1068
      .with( Mutator::setCaret,
1069
             () -> getTextEditor().getCaret() )
1070
      .with( Mutator::setSigilBegan,
1071
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1072
      .with( Mutator::setSigilEnded,
1073
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1074
      .with( Mutator::setRScript,
1075
             () -> w.getString( KEY_R_SCRIPT ) )
1076
      .with( Mutator::setRWorkingDir,
1077
             () -> w.getFile( KEY_R_DIR ).toPath() )
1078
      .with( Mutator::setCurlQuotes,
1079
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1080
      .with( Mutator::setAutoRemove,
1081
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1082
  }
1083
1084
  public ProcessorContext createProcessorContext() {
1085
    return createProcessorContextBuilder( NONE ).build();
1086
  }
1087
1088
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder(
1089
    final ExportFormat format ) {
1090
    final var textEditor = getTextEditor();
1091
    final var sourcePath = textEditor.getPath();
1092
1093
    return processorContextBuilder()
1094
      .with( Mutator::setSourcePath, sourcePath )
1095
      .with( Mutator::setExportFormat, format );
1096
  }
1097
1098
  /**
1099
   * @param targetPath Used when exporting to a PDF file (binary).
1100
   * @param format     Used when processors export to a new text format.
1101
   * @return A new {@link ProcessorContext} to use when creating an instance of
1102
   * {@link Processor}.
1103
   */
1104
  public ProcessorContext createProcessorContext(
1105
    final Path targetPath, final ExportFormat format ) {
1106
    assert targetPath != null;
1107
    assert format != null;
1108
1109
    return createProcessorContextBuilder( format )
1110
      .with( Mutator::setTargetPath, targetPath )
1111
      .build();
1112
  }
1113
1114
  /**
1115
   * @param sourcePath Used by {@link ProcessorFactory} to determine
1116
   *                   {@link Processor} type to create based on file type.
1117
   * @return A new {@link ProcessorContext} to use when creating an instance of
1118
   * {@link Processor}.
1119
   */
1120
  private ProcessorContext createProcessorContext( final Path sourcePath ) {
1121
    return processorContextBuilder()
1122
      .with( Mutator::setSourcePath, sourcePath )
1123
      .with( Mutator::setExportFormat, NONE )
1124
      .build();
1125
  }
1126
1127
  private TextResource createTextResource( final File file ) {
1128
    // TODO: Create PlainTextEditor that's returned by default.
1129
    return MediaType.valueFrom( file ) == TEXT_YAML
1130
      ? createDefinitionEditor( file )
1131
      : createMarkdownEditor( file );
1132
  }
1133
1134
  /**
1135
   * Creates an instance of {@link MarkdownEditor} that listens for both
1136
   * caret change events and text change events. Text change events must
1137
   * take priority over caret change events because it's possible to change
1138
   * the text without moving the caret (e.g., delete selected text).
1139
   *
1140
   * @param inputFile The file containing contents for the text editor.
1141
   * @return A non-null text editor.
1142
   */
1143
  private MarkdownEditor createMarkdownEditor( final File inputFile ) {
1144
    final var editor = new MarkdownEditor( inputFile, getWorkspace() );
1145
1146
    updateProcessors( editor );
1147
1148
    // Listener for editor modifications or caret position changes.
1149
    editor.addDirtyListener( ( c, o, n ) -> {
1150
      if( n ) {
1151
        // Reset the status bar after changing the text.
1152
        clue();
1153
1154
        // Processing the text may update the status bar.
1155
        process( getTextEditor() );
1156
1157
        // Update the caret position in the status bar.
1158
        CaretMovedEvent.fire( editor.getCaret() );
1159
      }
1160
    } );
1161
1162
    editor.addEventListener(
1163
      keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
1164
    );
1165
1166
    editor.addEventListener(
1167
      keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor )
1168
    );
1169
1170
    final var textArea = editor.getTextArea();
1171
1172
    // Spell check when the paragraph changes.
1173
    textArea
1174
      .plainTextChanges()
1175
      .filter( p -> !p.isIdentity() )
1176
      .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) );
1177
1178
    // Store the caret position to restore it after restarting the application.
1179
    textArea.caretPositionProperty().addListener(
1180
      ( c, o, n ) ->
1181
        getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n )
1182
    );
1183
1184
    // Set the active editor, which refreshes the preview panel.
1185
    mTextEditor.set( editor );
1186
1187
    // Check the entire document after the spellchecker is initialized (with
1188
    // a valid lexicon) so that only the current paragraph need be scanned
1189
    // while editing. (Technically, only the most recently modified word must
1190
    // be scanned.)
1191
    mSpellChecker.addListener(
1192
      ( c, o, n ) -> runLater(
1193
        () -> iterateEditors( mEditorSpeller::checkDocument )
1194
      )
1195
    );
1196
1197
    // Check the entire document after it has been loaded.
1198
    mEditorSpeller.checkDocument( mTextEditor.get() );
1199
1200
    return editor;
1201
  }
1202
1203
  /**
1204
   * Creates a processor for an editor, provided one doesn't already exist.
1205
   *
1206
   * @param editor The editor that potentially requires an associated processor.
1207
   */
1208
  private void updateProcessors( final TextEditor editor ) {
1209
    final var path = editor.getFile().toPath();
1210
1211
    mProcessors.computeIfAbsent(
1212
      editor, p -> createProcessors(
1213
        createProcessorContext( path ),
1214
        createHtmlPreviewProcessor()
1215
      )
1216
    );
1217
  }
1218
1219
  /**
1220
   * Removes a processor for an editor. This is required because a file may
1221
   * change type while editing (e.g., from plain Markdown to R Markdown).
1222
   * In the case that an editor's type changes, its associated processor must
1223
   * be changed accordingly.
1224
   *
1225
   * @param editor The editor that potentially requires an associated processor.
1226
   */
1227
  private void removeProcessor( final TextEditor editor ) {
1228
    mProcessors.remove( editor );
1229
  }
1230
1231
  /**
1232
   * Creates a {@link Processor} capable of rendering an HTML document onto
1233
   * a GUI widget.
1234
   *
1235
   * @return The {@link Processor} for rendering an HTML document.
1236
   */
1237
  private Processor<String> createHtmlPreviewProcessor() {
1238
    return new HtmlPreviewProcessor( getPreview() );
1239
  }
1240
1241
  /**
1242
   * Creates a spellchecker that accepts all words as correct. This allows
1243
   * the spellchecker property to be initialized to a known valid value.
1244
   *
1245
   * @return A wrapped {@link PermissiveSpeller}.
1246
   */
1247
  private ObjectProperty<SpellChecker> createSpellChecker() {
1248
    return new SimpleObjectProperty<>( new PermissiveSpeller() );
1249
  }
1250
1251
  private TextEditorSpellChecker createTextEditorSpellChecker(
1252
    final ObjectProperty<SpellChecker> spellChecker ) {
1253
    return new TextEditorSpellChecker( spellChecker );
1254
  }
1255
1256
  /**
1257
   * Delegates to {@link #autoinsert()}.
1258
   *
1259
   * @param keyEvent Ignored.
1260
   */
1261
  private void autoinsert( final KeyEvent keyEvent ) {
1262
    autoinsert();
1263
  }
1264
1265
  /**
1266
   * Finds a node that matches the word at the caret, then inserts the
1267
   * corresponding definition. The definition token delimiters depend on
1268
   * the type of file being edited.
1269
   */
1270
  public void autoinsert() {
1271
    mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() );
1272
  }
1273
1274
  private TextDefinition createDefinitionEditor() {
1275
    return createDefinitionEditor( DEFINITION_DEFAULT );
1276
  }
1277
1278
  private TextDefinition createDefinitionEditor( final File file ) {
1279
    final var editor = new DefinitionEditor( file, createTreeTransformer() );
1280
1281
    editor.addTreeChangeHandler( mTreeHandler );
1282
1283
    return editor;
1284
  }
1285
1286
  private TreeTransformer createTreeTransformer() {
1287
    return new YamlTreeTransformer();
1288
  }
1289
1290
  private Tooltip createTooltip( final File file ) {
1291
    final var path = file.toPath();
1292
    final var tooltip = new Tooltip( path.toString() );
1293
1294
    tooltip.setShowDelay( millis( 200 ) );
1295
1296
    return tooltip;
1297
  }
1298
1299
  public HtmlPreview getPreview() {
1300
    return mPreview;
1301
  }
1302
1303
  /**
1304
   * Returns the active text editor.
1305
   *
1306
   * @return The text editor that currently has focus.
1307
   */
1308
  public TextEditor getTextEditor() {
1309
    return mTextEditor.get();
1310
  }
1311
1312
  /**
1313
   * Returns the active text editor property.
1314
   *
1315
   * @return The property container for the active text editor.
1316
   */
1317
  public ReadOnlyObjectProperty<TextEditor> textEditorProperty() {
1318
    return mTextEditor;
1319
  }
1320
1321
  /**
1322
   * Returns the active text definition editor.
1323
   *
1324
   * @return The property container for the active definition editor.
1325
   */
1326
  public TextDefinition getTextDefinition() {
1327
    return mDefinitionEditor == null ? null : mDefinitionEditor.get();
1328
  }
1329
1330
  /**
1331
   * Returns the active variable definitions, without any interpolation.
1332
   * Interpolation is a responsibility of {@link Processor} instances.
1333
   *
1334
   * @return The key-value pairs, not interpolated.
1335
   */
1336
  private Map<String, String> getDefinitions() {
1337
    final var definitions = getTextDefinition();
1338
    return definitions == null ? new HashMap<>() : definitions.getDefinitions();
13371339
  }
13381340
M src/main/java/com/keenwrite/constants/GraphicsConstants.java
3333
   * @return The images loaded from the file name references.
3434
   */
35
  @SuppressWarnings( "SameParameterValue" )
3536
  private static List<Image> createImages( final String... keys ) {
3637
    final List<Image> images = new ArrayList<>( keys.length );
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
198198
    assert 0 <= offset && offset <= mTextArea.getLength();
199199
200
    mTextArea.moveTo( offset );
201
    mTextArea.requestFollowCaret();
200
    if( offset <= mTextArea.getLength() ) {
201
      mTextArea.moveTo( offset );
202
      mTextArea.requestFollowCaret();
203
    }
202204
  }
203205
M src/main/java/com/keenwrite/events/StatusEvent.java
102102
  private static boolean filter( final StackTraceElement e ) {
103103
    final var clazz = e.getClassName();
104
    return clazz.contains( PACKAGE_NAME ) ||
104
    return !(clazz.contains( PACKAGE_NAME ) ||
105105
      clazz.contains( "org.renjin." ) ||
106106
      clazz.contains( "sun." ) ||
107107
      clazz.contains( "flexmark." ) ||
108
      clazz.contains( "java." );
108
      clazz.contains( "java." ));
109109
  }
110110
M src/main/java/com/keenwrite/preferences/Workspace.java
313313
314314
        property.setValue( unmarshalled );
315
      } catch( final NoSuchElementException ignored ) {
315
      } catch( final NoSuchElementException ex ) {
316316
        // When no configuration (item), use the default value.
317
        clue( ex );
317318
      }
318319
    } );
...
516517
   * @return The value associated with the given {@link Key}.
517518
   */
519
  @SuppressWarnings( "unused" )
518520
  public int getInteger( final Key key ) {
519521
    assert key != null;
M src/main/java/com/keenwrite/preview/ImageReplacedElementFactory.java
6060
      return createElement( raster );
6161
    } catch( final Exception ex ) {
62
      clue( ex );
62
      clue( "Main.status.image.request.error.rasterize", ex );
6363
    }
6464
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
309309
   * @return The vector graphic transcoded into a raster image format.
310310
   */
311
  @SuppressWarnings( "unused" )
311312
  public static BufferedImage rasterizeImage(
312313
    final String svg, final double scale )
M src/main/java/com/keenwrite/ui/logging/LogView.java
103103
    columns.add( colTrace );
104104
105
    // Display the entire date by default.
106
    colDate.setPrefWidth( 135 );
107
108
    // Display most of the message by default.
109
    colMessage.setPrefWidth( 425 );
110
111
    // Display a large portion of the stack trace.
112
    colTrace.setPrefWidth( 600 );
113
105114
    mTable.setMaxWidth( Double.MAX_VALUE );
106
    mTable.setPrefWidth( 1024 );
115
    mTable.setPrefWidth( 1200 );
107116
    mTable.getSelectionModel().setSelectionMode( MULTIPLE );
108117
    mTable.setOnKeyPressed( event -> {
M src/main/resources/com/keenwrite/messages.properties
162162
Main.status.error.def.missing=No variable value found for ''{0}''
163163
Main.status.error.r=Error with [{0}...]: {1}
164
164165
Main.status.error.file.missing=Not found: ''{0}''
165166
Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
...
180181
Main.status.image.request.error.media=No media type for ''{0}''
181182
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
183
Main.status.image.request.error.rasterize=Rasterizer could not parse SVG image
182184
183185
Main.status.image.xhtml.image.download=Downloading ''{0}''
M src/main/resources/com/keenwrite/preview/webview.css
326326
}
327327
328
div.typewritten {
329
  font-family: monospace;
330
  font-size: 16px;
331
  font-weight: bold;
332
}
333
334