| 1 |     | |
| 2 | ||
| 1 | 3 | #  |
| 2 | 4 | |
| ... | ||
| 48 | 50 | * Real-time spell check |
| 49 | 51 | * Real-time rendering of math using TeX notation |
| 50 | * Diagrams: Mermaid, GraphViz, UML, sequence, timing, and [many more](https://kroki.io/)! | |
| 52 | * Real-time document statistics (with CJK word separation) | |
| 53 | * Diagrams: Mermaid, GraphViz, UML, sequence, timing, and more | |
| 51 | 54 | * Dark, custom, and responsive themes |
| 52 | 55 | * Interactive document outline |
| ... | ||
| 67 | 70 | ## Screenshots |
| 68 | 71 | |
| 69 | Diagram that includes variables: | |
| 72 | Diagrams that include variables: | |
| 70 | 73 | |
| 71 | 74 |  |
| 75 | ||
| 76 |  | |
| 72 | 77 | |
| 73 | 78 | Poem with locale settings: |
| 36 | 36 | |
| 37 | 37 | dependencies { |
| 38 | def v_junit = '5.5.1' | |
| 38 | def v_junit = '5.5.2' | |
| 39 | 39 | def v_flexmark = '0.62.2' |
| 40 | def v_jackson = '2.12.0' | |
| 41 | def v_batik = '1.13' | |
| 40 | def v_jackson = '2.12.1' | |
| 41 | def v_batik = '1.14' | |
| 42 | 42 | |
| 43 | 43 | // JavaFX |
| ... | ||
| 73 | 73 | // XML and XSL |
| 74 | 74 | implementation 'com.ximpleware:vtd-xml:2.13.4' |
| 75 | implementation 'net.sf.saxon:Saxon-HE:10.1' | |
| 76 | implementation 'xalan:xalan:2.7.2' | |
| 75 | implementation 'net.sf.saxon:Saxon-HE:10.3' | |
| 76 | //implementation 'xalan:xalan:2.7.2' | |
| 77 | 77 | |
| 78 | 78 | // HTML parsing and rendering |
| 3 | 3 | See the following documents for more information: |
| 4 | 4 | |
| 5 | * [i18n.md](i18n.md) -- Using internationalization features | |
| 5 | 6 | * [variables.md](variables.md) -- Variable definitions and interpolation |
| 6 | 7 | * [r.md](r.md) -- Call R functions within R Markdown documents |
| 7 | 8 | * [svg.md](svg.md) -- Fix known issues with displaying SVG files |
| 8 | 9 | * [themes.md](themes.md) -- Describes how to add and customize themes |
| 9 | * [texample.Rmd](texample.Rmd) -- Numerous examples of formulas | |
| 10 | 10 | * [credits.md](credits.md) -- Thanks to authors of contributing projects |
| 11 | * [samples](samples) -- Contains example documents | |
| 11 | 12 | |
| 12 | 13 |
| 1 | *Song of the Yellow Bird*: | |
| 2 | ||
| 3 | 翩翩黃鳥, | |
| 4 | 雌雄相依。 | |
| 5 | 念我之獨, | |
| 6 | 誰其與歸? | |
| 7 | ||
| 8 | English translation: | |
| 9 | ||
| 10 | Orioles fly smoothly | |
| 11 | Female and male cuddle close together | |
| 12 | Thinking of my loneliness | |
| 13 | Whom shall I go with? | |
| 14 | ||
| 15 | Fonts: | |
| 16 | ||
| 17 | * Regular: 활판 인쇄술 | |
| 18 | * Bold: **활판 인쇄술** | |
| 19 | * Monospace: `활판 인쇄술` | |
| 20 | * Monospace bold: **`활판 인쇄술`** | |
| 21 | * Math: $E=mc^2$ | |
| 22 | ||
| 23 | 1 |
| 1 | # Internationalization | |
| 2 | ||
| 3 | The application supports internationalization (I18N). There are multiple | |
| 4 | components to editing and previewing internationalized text documents. | |
| 5 | These include: | |
| 6 | ||
| 7 | * Fonts | |
| 8 | * Language | |
| 9 | ||
| 10 | Both fonts and language must be set for non-Latin-based text. | |
| 11 | ||
| 12 | # Fonts | |
| 13 | ||
| 14 | The text editors and preview panel have independent font settings. For | |
| 15 | all Chinese, Japanese, and Korean (CJK) fonts, you may have to type in | |
| 16 | the font family name directly. | |
| 17 | ||
| 18 | For example, CJK font families for the editor have the following names: | |
| 19 | ||
| 20 | * **Noto Sans CJK KR** --- Korean font | |
| 21 | * **Noto Sans CJK JP** --- Japanese font | |
| 22 | * **Noto Sans CJK HN** --- Chinese font | |
| 23 | * **Noto Sans CJK SC** --- Simplified Chinese font | |
| 24 | ||
| 25 | While CJK font familes for the preview have the following names: | |
| 26 | ||
| 27 | * **Noto Serif CJK KR** --- Korean font | |
| 28 | * **Noto Serif CJK JP** --- Japanese font | |
| 29 | * **Noto Serif CJK HN** --- Chinese font | |
| 30 | * **Noto Serif CJK SC** --- Simplified Chinese font | |
| 31 | ||
| 32 | ## Editor | |
| 33 | ||
| 34 | Complete the following steps to change the editor font: | |
| 35 | ||
| 36 | 1. Click **Edit → Preferences**. | |
| 37 | 1. Click **Fonts**. | |
| 38 | 1. Click **Change** under **Editor Font**. | |
| 39 | 1. Find the font name by typing or scrolling. | |
| 40 | 1. Click the desired font family. | |
| 41 | 1. Click **OK**. | |
| 42 | 1. Click **Apply**. | |
| 43 | ||
| 44 | The text editor font is changed. | |
| 45 | ||
| 46 | Note the following: | |
| 47 | ||
| 48 | * The font must be installed in the system for this to work. | |
| 49 | * You may have to edit the font name if it cannot be selected from the list. | |
| 50 | ||
| 51 | ## Preview | |
| 52 | ||
| 53 | The preview font defines has font styles for regular and monospace text. | |
| 54 | ||
| 55 | ### Regular | |
| 56 | ||
| 57 | Complete the following steps to change the regular preview font: | |
| 58 | ||
| 59 | 1. Click **Edit → Preferences**. | |
| 60 | 1. Click **Fonts**. | |
| 61 | 1. Click **Change** under **Preview Font** for the **Preview pane font name**. | |
| 62 | 1. Find the font name by typing or scrolling. | |
| 63 | 1. Click the desired font family. | |
| 64 | 1. Click **OK**. | |
| 65 | 1. Click **Apply**. | |
| 66 | ||
| 67 | The regular preview font is changed. | |
| 68 | ||
| 69 | ### Monospace | |
| 70 | ||
| 71 | Complete the following steps to change the monospace preview font: | |
| 72 | ||
| 73 | 1. Click **Edit → Preferences**. | |
| 74 | 1. Click **Fonts**. | |
| 75 | 1. Click **Change** under **Preview Font** for the **Monospace font name**. | |
| 76 | 1. Find the font name by typing or scrolling. | |
| 77 | 1. Click the desired font family. | |
| 78 | 1. Click **OK**. | |
| 79 | 1. Click **Apply**. | |
| 80 | ||
| 81 | The regular monospace font is changed. | |
| 82 | ||
| 83 | # Language | |
| 84 | ||
| 85 | Language settings control the locale that the application uses. When using | |
| 86 | a CJK font, for example, the application must also be instructed to use | |
| 87 | a particular locale. Change the locale as follows: | |
| 88 | ||
| 89 | 1. Click **Edit → Preferences**. | |
| 90 | 1. Click **Language**. | |
| 91 | 1. Select a value for **Locale**. | |
| 92 | 1. Click **Apply**. | |
| 93 | ||
| 94 | The language is set. | |
| 95 | ||
| 1 | 96 |
| 1 | --- | |
| 2 | formula: | |
| 3 | sqrt: | |
| 4 | value: "420" | |
| 5 | quadratic: | |
| 6 | a: "25" | |
| 7 | b: "84.906" | |
| 8 | c: "20" | |
| 9 | 1 |
| 1 |  | |
| 2 | ||
| 3 | Given the quadratic formula: | |
| 4 | ||
| 5 | $x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$ | |
| 6 | ||
| 7 | Formatted in an R Markdown document as follows: | |
| 8 | ||
| 9 | $x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$ | |
| 10 | ||
| 11 | We can substitute the following values: | |
| 12 | ||
| 13 | $a = `r# x(v$formula$quadratic$a)`, b = `r# x(v$formula$quadratic$b)`, c = `r# x(v$formula$quadratic$c)`$ | |
| 14 | ||
| 15 | `r# -x(v$formula$quadratic$b) + sqrt( v$formula$quadratic$b^2 - 4 * v$formula$quadratic$a * v$formula$quadratic$c )` | |
| 16 | ||
| 17 | To arrive at two solutions: | |
| 18 | ||
| 19 | $x = \frac{-b + \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) + sqrt( x(v$formula$quadratic$b)^2 - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$ | |
| 20 | ||
| 21 | $x = \frac{-b - \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) - sqrt( x(v$formula$quadratic$b)^2 - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$ | |
| 22 | ||
| 23 | Changing the variable values is reflected in the output immediately. | |
| 24 | 1 |
| 1 | *Song of the Yellow Bird*: | |
| 2 | ||
| 3 | 翩翩黃鳥, | |
| 4 | 雌雄相依。 | |
| 5 | 念我之獨, | |
| 6 | 誰其與歸? | |
| 7 | ||
| 8 | English translation: | |
| 9 | ||
| 10 | Orioles fly smoothly | |
| 11 | Female and male cuddle close together | |
| 12 | Thinking of my loneliness | |
| 13 | Whom shall I go with? | |
| 14 | ||
| 15 | Fonts: | |
| 16 | ||
| 17 | * Regular: 활판 인쇄술 | |
| 18 | * Bold: **활판 인쇄술** | |
| 19 | * Monospace: `활판 인쇄술` | |
| 20 | * Monospace bold: **`활판 인쇄술`** | |
| 21 | * Math: $E=mc^2$ | |
| 22 | ||
| 1 | 23 |
| 1 | --- | |
| 2 | formula: | |
| 3 | sqrt: | |
| 4 | value: "420" | |
| 5 | quadratic: | |
| 6 | a: "25" | |
| 7 | b: "84.906" | |
| 8 | c: "20" | |
| 1 | 9 |
| 1 |  | |
| 2 | ||
| 3 | Given the quadratic formula: | |
| 4 | ||
| 5 | $x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$ | |
| 6 | ||
| 7 | Formatted in an R Markdown document as follows: | |
| 8 | ||
| 9 | $x = \frac{-b \pm \sqrt{b^2 -4ac}}{2a}$ | |
| 10 | ||
| 11 | We can substitute the following values: | |
| 12 | ||
| 13 | $a = `r# x(v$formula$quadratic$a)`, b = `r# x(v$formula$quadratic$b)`, c = `r# x(v$formula$quadratic$c)`$ | |
| 14 | ||
| 15 | `r# -x(v$formula$quadratic$b) + sqrt( v$formula$quadratic$b^2 - 4 * v$formula$quadratic$a * v$formula$quadratic$c )` | |
| 16 | ||
| 17 | To arrive at two solutions: | |
| 18 | ||
| 19 | $x = \frac{-b + \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) + sqrt( x(v$formula$quadratic$b)^2 - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$ | |
| 20 | ||
| 21 | $x = \frac{-b - \sqrt{b^2 -4ac}}{2a} = `r# (-x(v$formula$quadratic$b) - sqrt( x(v$formula$quadratic$b)^2 - 4 * x(v$formula$quadratic$a) * x(v$formula$quadratic$c) )) / (2 * x(v$formula$quadratic$a))`$ | |
| 22 | ||
| 23 | Changing the variable values is reflected in the output immediately. | |
| 1 | 24 |
| 1 | #  | |
| 2 | ||
| 3 | # Real-time equation rendering | |
| 4 | ||
| 5 | Interpolated variables within R calculations, formatted as an equation: | |
| 6 | ||
| 7 | $\sqrt{`r#x( v$formula$sqrt$value)`} = \pm `r# round(sqrt(x( v$formula$sqrt$value )),5)`$ | |
| 8 | ||
| 9 | # Maxwell's equations | |
| 10 | ||
| 11 | $rot \vec{E} = \frac{1}{c} \frac{\partial{\vec{B}}}{\partial t}, div \vec{B} = 0$ | |
| 12 | ||
| 13 | $rot \vec{B} = \frac{1}{c} \frac{\partial{\vec{E}}}{\partial t} + \frac{4\pi}{c} \vec{j}, div \vec{E} = 4 \pi \rho_{\varepsilon}$ | |
| 14 | ||
| 15 | # Time-dependent Schrödinger equation | |
| 16 | ||
| 17 | $- \frac{{\hbar ^2 }}{{2m}}\frac{{\partial ^2 \psi (x,t)}}{{\partial x^2 }} + U(x)\psi (x,t) = i\hbar \frac{{\partial \psi (x,t)}}{{\partial t}}$ | |
| 18 | ||
| 19 | # Discrete-time Fourier transforms | |
| 20 | ||
| 21 | Unit step function: $u(n) \Leftrightarrow \frac{1}{1-e^{-jw}} + \sum_{k=-\infty}^{\infty} \pi \delta (\omega + 2\pi k)$ | |
| 22 | ||
| 23 | Shifted delta: $\delta (n - n_o ) \Leftrightarrow e^{ - j\omega n_o }$ | |
| 24 | ||
| 25 | # Faraday's Law | |
| 26 | ||
| 27 | $\oint_C {E \cdot d\ell = - \frac{d}{{dt}}} \int_S {B_n dA}$ | |
| 28 | ||
| 29 | # Infinite series | |
| 30 | ||
| 31 | $sin(x) = \sum_{n = 1}^{\infty} {\frac{{( { - 1})^{n - 1} x^{2n - 1} }}{{( {2n - 1})!}}}$ | |
| 32 | ||
| 33 | # Magnetic flux | |
| 34 | ||
| 35 | $\phi _m = \int_S {N{{B}} \cdot {{\hat n}}dA = } \int_S {NB_n dA}$ | |
| 36 | ||
| 37 | # Driven oscillation amplitude | |
| 38 | ||
| 39 | $A = \frac{{F_0 }}{{\sqrt {m^2 ( {\omega _0^2 - \omega ^2 } )^2 + b^2 \omega ^2 } }}$ | |
| 40 | ||
| 41 | # Optics | |
| 42 | ||
| 43 | $\phi = \frac{{2\pi }}{\lambda }a sin(\theta)$ | |
| 1 | 44 |
| 1 | #  | |
| 2 | ||
| 3 | # Real-time equation rendering | |
| 4 | ||
| 5 | Interpolated variables within R calculations, formatted as an equation: | |
| 6 | ||
| 7 | $\sqrt{`r#x( v$formula$sqrt$value)`} = \pm `r# round(sqrt(x( v$formula$sqrt$value )),5)`$ | |
| 8 | ||
| 9 | # Maxwell's equations | |
| 10 | ||
| 11 | $rot \vec{E} = \frac{1}{c} \frac{\partial{\vec{B}}}{\partial t}, div \vec{B} = 0$ | |
| 12 | ||
| 13 | $rot \vec{B} = \frac{1}{c} \frac{\partial{\vec{E}}}{\partial t} + \frac{4\pi}{c} \vec{j}, div \vec{E} = 4 \pi \rho_{\varepsilon}$ | |
| 14 | ||
| 15 | # Time-dependent Schrödinger equation | |
| 16 | ||
| 17 | $- \frac{{\hbar ^2 }}{{2m}}\frac{{\partial ^2 \psi (x,t)}}{{\partial x^2 }} + U(x)\psi (x,t) = i\hbar \frac{{\partial \psi (x,t)}}{{\partial t}}$ | |
| 18 | ||
| 19 | # Discrete-time Fourier transforms | |
| 20 | ||
| 21 | Unit step function: $u(n) \Leftrightarrow \frac{1}{1-e^{-jw}} + \sum_{k=-\infty}^{\infty} \pi \delta (\omega + 2\pi k)$ | |
| 22 | ||
| 23 | Shifted delta: $\delta (n - n_o ) \Leftrightarrow e^{ - j\omega n_o }$ | |
| 24 | ||
| 25 | # Faraday's Law | |
| 26 | ||
| 27 | $\oint_C {E \cdot d\ell = - \frac{d}{{dt}}} \int_S {B_n dA}$ | |
| 28 | ||
| 29 | # Infinite series | |
| 30 | ||
| 31 | $sin(x) = \sum_{n = 1}^{\infty} {\frac{{( { - 1})^{n - 1} x^{2n - 1} }}{{( {2n - 1})!}}}$ | |
| 32 | ||
| 33 | # Magnetic flux | |
| 34 | ||
| 35 | $\phi _m = \int_S {N{{B}} \cdot {{\hat n}}dA = } \int_S {NB_n dA}$ | |
| 36 | ||
| 37 | # Driven oscillation amplitude | |
| 38 | ||
| 39 | $A = \frac{{F_0 }}{{\sqrt {m^2 ( {\omega _0^2 - \omega ^2 } )^2 + b^2 \omega ^2 } }}$ | |
| 40 | ||
| 41 | # Optics | |
| 42 | ||
| 43 | $\phi = \frac{{2\pi }}{\lambda }a sin(\theta)$ | |
| 44 | 1 |
| 1 | 1 | #!/usr/bin/env bash |
| 2 | 2 | |
| 3 | # Writes the name for all OTF files found in the current directory or lower | |
| 3 | # Outputs font names for all font files. | |
| 4 | 4 | |
| 5 | 5 | find src/main/resources/fonts -type f \( -name "*otf" -o -name "*ttf" \) -exec \ |
| 223 | 223 | public static final String ICON_SIZE_DEFAULT = "1.2em"; |
| 224 | 224 | |
| 225 | /** | |
| 226 | * Default server name for rendering diagrams. | |
| 227 | * <p> | |
| 228 | * TODO: Make this a preference so that local installs are possible. | |
| 229 | */ | |
| 225 | 230 | public static final String DIAGRAM_SERVER_NAME = "kroki.io"; |
| 231 | ||
| 232 | /** | |
| 233 | * Application action messages properties prefix. | |
| 234 | */ | |
| 235 | public static final String ACTION_PREFIX = "Action."; | |
| 226 | 236 | |
| 227 | 237 | /** |
| 228 | 238 | * Prevent instantiation. |
| 229 | 239 | */ |
| 230 | 240 | private Constants() { |
| 241 | } | |
| 242 | ||
| 243 | /** | |
| 244 | * Converts from points to pixels because FlyingSaucer cannot handle points | |
| 245 | * properly. This is used to convert font sizes. | |
| 246 | * | |
| 247 | * @param points The points to convert to pixels. | |
| 248 | * @return The given number of points in equivalent pixels. | |
| 249 | */ | |
| 250 | public static int toPixels( final double points ) { | |
| 251 | return (int) (points * (1 + 1 / 3f)); | |
| 231 | 252 | } |
| 232 | 253 |
| 14 | 14 | import com.keenwrite.events.TextEditorFocusEvent; |
| 15 | 15 | import com.keenwrite.io.MediaType; |
| 16 | import com.keenwrite.outline.DocumentOutline; | |
| 17 | import com.keenwrite.preferences.Key; | |
| 18 | import com.keenwrite.preferences.Workspace; | |
| 19 | import com.keenwrite.preview.HtmlPanel; | |
| 20 | import com.keenwrite.preview.HtmlPreview; | |
| 21 | import com.keenwrite.processors.Processor; | |
| 22 | import com.keenwrite.processors.ProcessorContext; | |
| 23 | import com.keenwrite.processors.ProcessorFactory; | |
| 24 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 25 | import com.keenwrite.service.events.Notifier; | |
| 26 | import com.keenwrite.sigils.RSigilOperator; | |
| 27 | import com.keenwrite.sigils.SigilOperator; | |
| 28 | import com.keenwrite.sigils.Tokens; | |
| 29 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 30 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 31 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 32 | import javafx.application.Platform; | |
| 33 | import javafx.beans.property.*; | |
| 34 | import javafx.collections.ListChangeListener; | |
| 35 | import javafx.event.ActionEvent; | |
| 36 | import javafx.event.Event; | |
| 37 | import javafx.event.EventHandler; | |
| 38 | import javafx.scene.Node; | |
| 39 | import javafx.scene.Scene; | |
| 40 | import javafx.scene.control.SplitPane; | |
| 41 | import javafx.scene.control.Tab; | |
| 42 | import javafx.scene.control.TabPane; | |
| 43 | import javafx.scene.control.Tooltip; | |
| 44 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 45 | import javafx.scene.input.KeyEvent; | |
| 46 | import javafx.stage.Stage; | |
| 47 | import javafx.stage.Window; | |
| 48 | import org.greenrobot.eventbus.Subscribe; | |
| 49 | ||
| 50 | import java.io.File; | |
| 51 | import java.io.FileNotFoundException; | |
| 52 | import java.nio.file.Path; | |
| 53 | import java.util.*; | |
| 54 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 55 | import java.util.function.Function; | |
| 56 | import java.util.stream.Collectors; | |
| 57 | ||
| 58 | import static com.keenwrite.Constants.*; | |
| 59 | import static com.keenwrite.ExportFormat.NONE; | |
| 60 | import static com.keenwrite.Messages.get; | |
| 61 | import static com.keenwrite.events.Bus.register; | |
| 62 | import static com.keenwrite.events.StatusEvent.clue; | |
| 63 | import static com.keenwrite.io.MediaType.*; | |
| 64 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 65 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 66 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 67 | import static java.util.stream.Collectors.groupingBy; | |
| 68 | import static javafx.application.Platform.runLater; | |
| 69 | import static javafx.scene.control.ButtonType.NO; | |
| 70 | import static javafx.scene.control.ButtonType.YES; | |
| 71 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 72 | import static javafx.scene.input.KeyCode.SPACE; | |
| 73 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 74 | import static javafx.util.Duration.millis; | |
| 75 | import static javax.swing.SwingUtilities.invokeLater; | |
| 76 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 77 | ||
| 78 | /** | |
| 79 | * Responsible for wiring together the main application components for a | |
| 80 | * particular workspace (project). These include the definition views, | |
| 81 | * text editors, and preview pane along with any corresponding controllers. | |
| 82 | */ | |
| 83 | public final class MainPane extends SplitPane { | |
| 84 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 85 | ||
| 86 | /** | |
| 87 | * Used when opening files to determine how each file should be binned and | |
| 88 | * therefore what tab pane to be opened within. | |
| 89 | */ | |
| 90 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 91 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED | |
| 92 | ); | |
| 93 | ||
| 94 | /** | |
| 95 | * Prevents re-instantiation of processing classes. | |
| 96 | */ | |
| 97 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 98 | new HashMap<>(); | |
| 99 | ||
| 100 | private final Workspace mWorkspace; | |
| 101 | ||
| 102 | /** | |
| 103 | * Groups similar file type tabs together. | |
| 104 | */ | |
| 105 | private final Map<MediaType, TabPane> mTabPanes = new HashMap<>(); | |
| 106 | ||
| 107 | /** | |
| 108 | * Stores definition names and values. | |
| 109 | */ | |
| 110 | private final Map<String, String> mResolvedMap = | |
| 111 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 112 | ||
| 113 | /** | |
| 114 | * Renders the actively selected plain text editor tab. | |
| 115 | */ | |
| 116 | private final HtmlPreview mPreview; | |
| 117 | ||
| 118 | /** | |
| 119 | * Provides an interactive document outline. | |
| 120 | */ | |
| 121 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 122 | ||
| 123 | /** | |
| 124 | * Changing the active editor fires the value changed event. This allows | |
| 125 | * refreshes to happen when external definitions are modified and need to | |
| 126 | * trigger the processing chain. | |
| 127 | */ | |
| 128 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 129 | createActiveTextEditor(); | |
| 130 | ||
| 131 | /** | |
| 132 | * Changing the active definition editor fires the value changed event. This | |
| 133 | * allows refreshes to happen when external definitions are modified and need | |
| 134 | * to trigger the processing chain. | |
| 135 | */ | |
| 136 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 137 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 138 | ||
| 139 | /** | |
| 140 | * Tracks the number of detached tab panels opened into their own windows, | |
| 141 | * which allows unique identification of subordinate windows by their title. | |
| 142 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 143 | */ | |
| 144 | private byte mWindowCount; | |
| 145 | ||
| 146 | /** | |
| 147 | * Called when the definition data is changed. | |
| 148 | */ | |
| 149 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 150 | event -> { | |
| 151 | final var editor = mActiveDefinitionEditor.get(); | |
| 152 | ||
| 153 | resolve( editor ); | |
| 154 | process( getActiveTextEditor() ); | |
| 155 | save( editor ); | |
| 156 | }; | |
| 157 | ||
| 158 | /** | |
| 159 | * Adds all content panels to the main user interface. This will load the | |
| 160 | * configuration settings from the workspace to reproduce the settings from | |
| 161 | * a previous session. | |
| 162 | */ | |
| 163 | public MainPane( final Workspace workspace ) { | |
| 164 | mWorkspace = workspace; | |
| 165 | mPreview = new HtmlPreview( workspace ); | |
| 166 | ||
| 167 | open( bin( getRecentFiles() ) ); | |
| 168 | viewPreview(); | |
| 169 | setDividerPositions( calculateDividerPositions() ); | |
| 170 | ||
| 171 | // Once the main scene's window regains focus, update the active definition | |
| 172 | // editor to the currently selected tab. | |
| 173 | runLater( | |
| 174 | () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 175 | // Order matters here. We want to close all the tabs to ensure each | |
| 176 | // is saved, but after they are closed, the workspace should still | |
| 177 | // retain the list of files that were open. If this line came after | |
| 178 | // closing, then restarting the application would list no files. | |
| 179 | mWorkspace.save(); | |
| 180 | ||
| 181 | if( closeAll() ) { | |
| 182 | Platform.exit(); | |
| 183 | System.exit( 0 ); | |
| 184 | } | |
| 185 | else { | |
| 186 | event.consume(); | |
| 187 | } | |
| 188 | } ) | |
| 189 | ); | |
| 190 | ||
| 191 | register( this ); | |
| 192 | } | |
| 193 | ||
| 194 | @Subscribe | |
| 195 | public void handle( final TextEditorFocusEvent event ) { | |
| 196 | mActiveTextEditor.set( event.get() ); | |
| 197 | } | |
| 198 | ||
| 199 | @Subscribe | |
| 200 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 201 | mActiveDefinitionEditor.set( event.get() ); | |
| 202 | } | |
| 203 | ||
| 204 | /** | |
| 205 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 206 | * | |
| 207 | * @param event The event to process, must contain a valid file reference. | |
| 208 | */ | |
| 209 | @Subscribe | |
| 210 | public void handle( final FileOpenEvent event ) { | |
| 211 | final File eventFile; | |
| 212 | final var eventUri = event.getUri(); | |
| 213 | ||
| 214 | if( eventUri.isAbsolute() ) { | |
| 215 | eventFile = new File( eventUri.getPath() ); | |
| 216 | } | |
| 217 | else { | |
| 218 | final var activeFile = getActiveTextEditor().getFile(); | |
| 219 | final var parent = activeFile.getParentFile(); | |
| 220 | ||
| 221 | if( parent == null ) { | |
| 222 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 223 | return; | |
| 224 | } | |
| 225 | else { | |
| 226 | final var parentPath = parent.getAbsolutePath(); | |
| 227 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | runLater( () -> open( eventFile ) ); | |
| 232 | } | |
| 233 | ||
| 234 | @Subscribe | |
| 235 | public void handle( final CaretNavigationEvent event ) { | |
| 236 | runLater( () -> { | |
| 237 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 238 | textArea.moveTo( event.getOffset() ); | |
| 239 | textArea.requestFollowCaret(); | |
| 240 | textArea.requestFocus(); | |
| 241 | } ); | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 246 | */ | |
| 247 | private double[] calculateDividerPositions() { | |
| 248 | final var ratio = 100f / getItems().size() / 100; | |
| 249 | final var positions = getDividerPositions(); | |
| 250 | ||
| 251 | for( int i = 0; i < positions.length; i++ ) { | |
| 252 | positions[ i ] = ratio * i; | |
| 253 | } | |
| 254 | ||
| 255 | return positions; | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Opens all the files into the application, provided the paths are unique. | |
| 260 | * This may only be called for any type of files that a user can edit | |
| 261 | * (i.e., update and persist), such as definitions and text files. | |
| 262 | * | |
| 263 | * @param files The list of files to open. | |
| 264 | */ | |
| 265 | public void open( final List<File> files ) { | |
| 266 | files.forEach( this::open ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * This opens the given file. Since the preview pane is not a file that | |
| 271 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 272 | * | |
| 273 | * @param file The file to open. | |
| 274 | */ | |
| 275 | private void open( final File file ) { | |
| 276 | final var tab = createTab( file ); | |
| 277 | final var node = tab.getContent(); | |
| 278 | final var mediaType = MediaType.valueFrom( file ); | |
| 279 | final var tabPane = obtainTabPane( mediaType ); | |
| 280 | ||
| 281 | tab.setTooltip( createTooltip( file ) ); | |
| 282 | tabPane.setFocusTraversable( false ); | |
| 283 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 284 | tabPane.getTabs().add( tab ); | |
| 285 | ||
| 286 | // Attach the tab scene factory for new tab panes. | |
| 287 | if( !getItems().contains( tabPane ) ) { | |
| 288 | addTabPane( | |
| 289 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 294 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Opens a new text editor document using the default document file name. | |
| 298 | */ | |
| 299 | public void newTextEditor() { | |
| 300 | open( DOCUMENT_DEFAULT ); | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Opens a new definition editor document using the default definition | |
| 305 | * file name. | |
| 306 | */ | |
| 307 | public void newDefinitionEditor() { | |
| 308 | open( DEFINITION_DEFAULT ); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 313 | * that they save themselves. | |
| 314 | */ | |
| 315 | public void saveAll() { | |
| 316 | mTabPanes.forEach( | |
| 317 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 318 | final var node = tab.getContent(); | |
| 319 | if( node instanceof TextEditor ) { | |
| 320 | save( ((TextEditor) node) ); | |
| 321 | } | |
| 322 | } ) | |
| 323 | ); | |
| 324 | } | |
| 325 | ||
| 326 | /** | |
| 327 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 328 | * checking if modified first because if the user swaps external media from | |
| 329 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 330 | * the user: save always re-saves. Also, it's less code. | |
| 331 | */ | |
| 332 | public void save() { | |
| 333 | save( getActiveTextEditor() ); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Saves the active {@link TextEditor} under a new name. | |
| 338 | * | |
| 339 | * @param file The new active editor {@link File} reference. | |
| 340 | */ | |
| 341 | public void saveAs( final File file ) { | |
| 342 | assert file != null; | |
| 343 | final var editor = getActiveTextEditor(); | |
| 344 | final var tab = getTab( editor ); | |
| 345 | ||
| 346 | editor.rename( file ); | |
| 347 | tab.ifPresent( t -> { | |
| 348 | t.setText( editor.getFilename() ); | |
| 349 | t.setTooltip( createTooltip( file ) ); | |
| 350 | } ); | |
| 351 | ||
| 352 | save(); | |
| 353 | } | |
| 354 | ||
| 355 | /** | |
| 356 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 357 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 358 | * | |
| 359 | * @param resource The resource to export. | |
| 360 | */ | |
| 361 | private void save( final TextResource resource ) { | |
| 362 | try { | |
| 363 | resource.save(); | |
| 364 | } catch( final Exception ex ) { | |
| 365 | clue( ex ); | |
| 366 | sNotifier.alert( | |
| 367 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 368 | ); | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 374 | * | |
| 375 | * @return {@code true} when all editors, modified or otherwise, were | |
| 376 | * permitted to close; {@code false} when one or more editors were modified | |
| 377 | * and the user requested no closing. | |
| 378 | */ | |
| 379 | public boolean closeAll() { | |
| 380 | var closable = true; | |
| 381 | ||
| 382 | for( final var entry : mTabPanes.entrySet() ) { | |
| 383 | final var tabPane = entry.getValue(); | |
| 384 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 385 | ||
| 386 | while( tabIterator.hasNext() ) { | |
| 387 | final var tab = tabIterator.next(); | |
| 388 | final var resource = tab.getContent(); | |
| 389 | ||
| 390 | // The definition panes auto-save, so being specific here prevents | |
| 391 | // closing the definitions in the situation where the user wants to | |
| 392 | // continue editing (i.e., possibly save unsaved work). | |
| 393 | if( !(resource instanceof TextEditor) ) { | |
| 394 | continue; | |
| 395 | } | |
| 396 | ||
| 397 | if( canClose( (TextEditor) resource ) ) { | |
| 398 | tabIterator.remove(); | |
| 399 | close( tab ); | |
| 400 | } | |
| 401 | else { | |
| 402 | closable = false; | |
| 403 | } | |
| 404 | } | |
| 405 | } | |
| 406 | ||
| 407 | return closable; | |
| 408 | } | |
| 409 | ||
| 410 | /** | |
| 411 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 412 | * event. | |
| 413 | * | |
| 414 | * @param tab The {@link Tab} that was closed. | |
| 415 | */ | |
| 416 | private void close( final Tab tab ) { | |
| 417 | final var handler = tab.getOnClosed(); | |
| 418 | ||
| 419 | if( handler != null ) { | |
| 420 | handler.handle( new ActionEvent() ); | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 426 | */ | |
| 427 | public void close() { | |
| 428 | final var editor = getActiveTextEditor(); | |
| 429 | if( canClose( editor ) ) { | |
| 430 | close( editor ); | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | /** | |
| 435 | * Closes the given {@link TextResource}. This must not be called from within | |
| 436 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 437 | * concurrent modification exception be thrown. | |
| 438 | * | |
| 439 | * @param resource The {@link TextResource} to close, without confirming with | |
| 440 | * the user. | |
| 441 | */ | |
| 442 | private void close( final TextResource resource ) { | |
| 443 | getTab( resource ).ifPresent( | |
| 444 | ( tab ) -> { | |
| 445 | tab.getTabPane().getTabs().remove( tab ); | |
| 446 | close( tab ); | |
| 447 | } | |
| 448 | ); | |
| 449 | } | |
| 450 | ||
| 451 | /** | |
| 452 | * Answers whether the given {@link TextResource} may be closed. | |
| 453 | * | |
| 454 | * @param editor The {@link TextResource} to try closing. | |
| 455 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 456 | * the user has requested to keep the editor open. | |
| 457 | */ | |
| 458 | private boolean canClose( final TextResource editor ) { | |
| 459 | final var editorTab = getTab( editor ); | |
| 460 | final var canClose = new AtomicBoolean( true ); | |
| 461 | ||
| 462 | if( editor.isModified() ) { | |
| 463 | final var filename = new StringBuilder(); | |
| 464 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 465 | ||
| 466 | final var message = sNotifier.createNotification( | |
| 467 | Messages.get( "Alert.file.close.title" ), | |
| 468 | Messages.get( "Alert.file.close.text" ), | |
| 469 | filename.toString() | |
| 470 | ); | |
| 471 | ||
| 472 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 473 | ||
| 474 | dialog.showAndWait().ifPresent( | |
| 475 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 476 | ); | |
| 477 | } | |
| 478 | ||
| 479 | return canClose.get(); | |
| 480 | } | |
| 481 | ||
| 482 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 483 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 484 | ||
| 485 | editor.addListener( ( c, o, n ) -> { | |
| 486 | if( n != null ) { | |
| 487 | mPreview.setBaseUri( n.getPath() ); | |
| 488 | process( n ); | |
| 489 | } | |
| 490 | } ); | |
| 491 | ||
| 492 | return editor; | |
| 493 | } | |
| 494 | ||
| 495 | /** | |
| 496 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 497 | */ | |
| 498 | public void viewPreview() { | |
| 499 | viewTab( mPreview, TEXT_HTML, get( "Pane.preview.title" ) ); | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * Adds the document outline tab to its own, singular tab pane. | |
| 504 | */ | |
| 505 | public void viewOutline() { | |
| 506 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, get( "Pane.outline.title" ) ); | |
| 507 | } | |
| 508 | ||
| 509 | private void viewTab( | |
| 510 | final Node node, final MediaType mediaType, final String name ) { | |
| 511 | final var tabPane = obtainTabPane( mediaType ); | |
| 512 | ||
| 513 | for( final var tab : tabPane.getTabs() ) { | |
| 514 | if( tab.getContent() == node ) { | |
| 515 | return; | |
| 516 | } | |
| 517 | } | |
| 518 | ||
| 519 | tabPane.getTabs().add( createTab( name, node ) ); | |
| 16 | import com.keenwrite.preferences.Key; | |
| 17 | import com.keenwrite.preferences.Workspace; | |
| 18 | import com.keenwrite.preview.HtmlPanel; | |
| 19 | import com.keenwrite.preview.HtmlPreview; | |
| 20 | import com.keenwrite.processors.Processor; | |
| 21 | import com.keenwrite.processors.ProcessorContext; | |
| 22 | import com.keenwrite.processors.ProcessorFactory; | |
| 23 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 24 | import com.keenwrite.service.events.Notifier; | |
| 25 | import com.keenwrite.sigils.RSigilOperator; | |
| 26 | import com.keenwrite.sigils.SigilOperator; | |
| 27 | import com.keenwrite.sigils.Tokens; | |
| 28 | import com.keenwrite.sigils.YamlSigilOperator; | |
| 29 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 30 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 31 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 32 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 33 | import javafx.application.Platform; | |
| 34 | import javafx.beans.property.*; | |
| 35 | import javafx.collections.ListChangeListener; | |
| 36 | import javafx.event.ActionEvent; | |
| 37 | import javafx.event.Event; | |
| 38 | import javafx.event.EventHandler; | |
| 39 | import javafx.scene.Node; | |
| 40 | import javafx.scene.Scene; | |
| 41 | import javafx.scene.control.SplitPane; | |
| 42 | import javafx.scene.control.Tab; | |
| 43 | import javafx.scene.control.TabPane; | |
| 44 | import javafx.scene.control.Tooltip; | |
| 45 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 46 | import javafx.scene.input.KeyEvent; | |
| 47 | import javafx.stage.Stage; | |
| 48 | import javafx.stage.Window; | |
| 49 | import org.greenrobot.eventbus.Subscribe; | |
| 50 | ||
| 51 | import java.io.File; | |
| 52 | import java.io.FileNotFoundException; | |
| 53 | import java.nio.file.Path; | |
| 54 | import java.util.*; | |
| 55 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 56 | import java.util.function.Function; | |
| 57 | import java.util.stream.Collectors; | |
| 58 | ||
| 59 | import static com.keenwrite.Constants.*; | |
| 60 | import static com.keenwrite.ExportFormat.NONE; | |
| 61 | import static com.keenwrite.Messages.get; | |
| 62 | import static com.keenwrite.events.Bus.register; | |
| 63 | import static com.keenwrite.events.StatusEvent.clue; | |
| 64 | import static com.keenwrite.io.MediaType.*; | |
| 65 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 66 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 67 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 68 | import static java.util.stream.Collectors.groupingBy; | |
| 69 | import static javafx.application.Platform.runLater; | |
| 70 | import static javafx.scene.control.ButtonType.NO; | |
| 71 | import static javafx.scene.control.ButtonType.YES; | |
| 72 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 73 | import static javafx.scene.input.KeyCode.SPACE; | |
| 74 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 75 | import static javafx.util.Duration.millis; | |
| 76 | import static javax.swing.SwingUtilities.invokeLater; | |
| 77 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 78 | ||
| 79 | /** | |
| 80 | * Responsible for wiring together the main application components for a | |
| 81 | * particular workspace (project). These include the definition views, | |
| 82 | * text editors, and preview pane along with any corresponding controllers. | |
| 83 | */ | |
| 84 | public final class MainPane extends SplitPane { | |
| 85 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 86 | ||
| 87 | /** | |
| 88 | * Used when opening files to determine how each file should be binned and | |
| 89 | * therefore what tab pane to be opened within. | |
| 90 | */ | |
| 91 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 92 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, TEXT_R_XML, UNDEFINED | |
| 93 | ); | |
| 94 | ||
| 95 | /** | |
| 96 | * Prevents re-instantiation of processing classes. | |
| 97 | */ | |
| 98 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 99 | new HashMap<>(); | |
| 100 | ||
| 101 | private final Workspace mWorkspace; | |
| 102 | ||
| 103 | /** | |
| 104 | * Groups similar file type tabs together. | |
| 105 | */ | |
| 106 | private final Map<MediaType, TabPane> mTabPanes = new HashMap<>(); | |
| 107 | ||
| 108 | /** | |
| 109 | * Stores definition names and values. | |
| 110 | */ | |
| 111 | private final Map<String, String> mResolvedMap = | |
| 112 | new HashMap<>( MAP_SIZE_DEFAULT ); | |
| 113 | ||
| 114 | /** | |
| 115 | * Renders the actively selected plain text editor tab. | |
| 116 | */ | |
| 117 | private final HtmlPreview mPreview; | |
| 118 | ||
| 119 | /** | |
| 120 | * Provides an interactive document outline. | |
| 121 | */ | |
| 122 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 123 | ||
| 124 | /** | |
| 125 | * Changing the active editor fires the value changed event. This allows | |
| 126 | * refreshes to happen when external definitions are modified and need to | |
| 127 | * trigger the processing chain. | |
| 128 | */ | |
| 129 | private final ObjectProperty<TextEditor> mActiveTextEditor = | |
| 130 | createActiveTextEditor(); | |
| 131 | ||
| 132 | /** | |
| 133 | * Changing the active definition editor fires the value changed event. This | |
| 134 | * allows refreshes to happen when external definitions are modified and need | |
| 135 | * to trigger the processing chain. | |
| 136 | */ | |
| 137 | private final ObjectProperty<TextDefinition> mActiveDefinitionEditor = | |
| 138 | createActiveDefinitionEditor( mActiveTextEditor ); | |
| 139 | ||
| 140 | /** | |
| 141 | * Tracks the number of detached tab panels opened into their own windows, | |
| 142 | * which allows unique identification of subordinate windows by their title. | |
| 143 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 144 | */ | |
| 145 | private byte mWindowCount; | |
| 146 | ||
| 147 | /** | |
| 148 | * Called when the definition data is changed. | |
| 149 | */ | |
| 150 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 151 | event -> { | |
| 152 | final var editor = mActiveDefinitionEditor.get(); | |
| 153 | ||
| 154 | resolve( editor ); | |
| 155 | process( getActiveTextEditor() ); | |
| 156 | save( editor ); | |
| 157 | }; | |
| 158 | ||
| 159 | private final DocumentStatistics mStatistics; | |
| 160 | ||
| 161 | /** | |
| 162 | * Adds all content panels to the main user interface. This will load the | |
| 163 | * configuration settings from the workspace to reproduce the settings from | |
| 164 | * a previous session. | |
| 165 | */ | |
| 166 | public MainPane( final Workspace workspace ) { | |
| 167 | mWorkspace = workspace; | |
| 168 | mPreview = new HtmlPreview( workspace ); | |
| 169 | mStatistics = new DocumentStatistics( workspace ); | |
| 170 | ||
| 171 | open( bin( getRecentFiles() ) ); | |
| 172 | viewPreview(); | |
| 173 | setDividerPositions( calculateDividerPositions() ); | |
| 174 | ||
| 175 | // Once the main scene's window regains focus, update the active definition | |
| 176 | // editor to the currently selected tab. | |
| 177 | runLater( | |
| 178 | () -> getWindow().setOnCloseRequest( ( event ) -> { | |
| 179 | // Order matters here. We want to close all the tabs to ensure each | |
| 180 | // is saved, but after they are closed, the workspace should still | |
| 181 | // retain the list of files that were open. If this line came after | |
| 182 | // closing, then restarting the application would list no files. | |
| 183 | mWorkspace.save(); | |
| 184 | ||
| 185 | if( closeAll() ) { | |
| 186 | Platform.exit(); | |
| 187 | System.exit( 0 ); | |
| 188 | } | |
| 189 | else { | |
| 190 | event.consume(); | |
| 191 | } | |
| 192 | } ) | |
| 193 | ); | |
| 194 | ||
| 195 | register( this ); | |
| 196 | } | |
| 197 | ||
| 198 | @Subscribe | |
| 199 | public void handle( final TextEditorFocusEvent event ) { | |
| 200 | mActiveTextEditor.set( event.get() ); | |
| 201 | } | |
| 202 | ||
| 203 | @Subscribe | |
| 204 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 205 | mActiveDefinitionEditor.set( event.get() ); | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * Typically called when a file name is clicked in the {@link HtmlPanel}. | |
| 210 | * | |
| 211 | * @param event The event to process, must contain a valid file reference. | |
| 212 | */ | |
| 213 | @Subscribe | |
| 214 | public void handle( final FileOpenEvent event ) { | |
| 215 | final File eventFile; | |
| 216 | final var eventUri = event.getUri(); | |
| 217 | ||
| 218 | if( eventUri.isAbsolute() ) { | |
| 219 | eventFile = new File( eventUri.getPath() ); | |
| 220 | } | |
| 221 | else { | |
| 222 | final var activeFile = getActiveTextEditor().getFile(); | |
| 223 | final var parent = activeFile.getParentFile(); | |
| 224 | ||
| 225 | if( parent == null ) { | |
| 226 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 227 | return; | |
| 228 | } | |
| 229 | else { | |
| 230 | final var parentPath = parent.getAbsolutePath(); | |
| 231 | eventFile = Path.of( parentPath, eventUri.getPath() ).toFile(); | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | runLater( () -> open( eventFile ) ); | |
| 236 | } | |
| 237 | ||
| 238 | @Subscribe | |
| 239 | public void handle( final CaretNavigationEvent event ) { | |
| 240 | runLater( () -> { | |
| 241 | final var textArea = getActiveTextEditor().getTextArea(); | |
| 242 | textArea.moveTo( event.getOffset() ); | |
| 243 | textArea.requestFollowCaret(); | |
| 244 | textArea.requestFocus(); | |
| 245 | } ); | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * TODO: Load divider positions from exported settings, see bin() comment. | |
| 250 | */ | |
| 251 | private double[] calculateDividerPositions() { | |
| 252 | final var ratio = 100f / getItems().size() / 100; | |
| 253 | final var positions = getDividerPositions(); | |
| 254 | ||
| 255 | for( int i = 0; i < positions.length; i++ ) { | |
| 256 | positions[ i ] = ratio * i; | |
| 257 | } | |
| 258 | ||
| 259 | return positions; | |
| 260 | } | |
| 261 | ||
| 262 | /** | |
| 263 | * Opens all the files into the application, provided the paths are unique. | |
| 264 | * This may only be called for any type of files that a user can edit | |
| 265 | * (i.e., update and persist), such as definitions and text files. | |
| 266 | * | |
| 267 | * @param files The list of files to open. | |
| 268 | */ | |
| 269 | public void open( final List<File> files ) { | |
| 270 | files.forEach( this::open ); | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * This opens the given file. Since the preview pane is not a file that | |
| 275 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 276 | * | |
| 277 | * @param file The file to open. | |
| 278 | */ | |
| 279 | private void open( final File file ) { | |
| 280 | final var tab = createTab( file ); | |
| 281 | final var node = tab.getContent(); | |
| 282 | final var mediaType = MediaType.valueFrom( file ); | |
| 283 | final var tabPane = obtainTabPane( mediaType ); | |
| 284 | ||
| 285 | tab.setTooltip( createTooltip( file ) ); | |
| 286 | tabPane.setFocusTraversable( false ); | |
| 287 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 288 | tabPane.getTabs().add( tab ); | |
| 289 | ||
| 290 | // Attach the tab scene factory for new tab panes. | |
| 291 | if( !getItems().contains( tabPane ) ) { | |
| 292 | addTabPane( | |
| 293 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 294 | ); | |
| 295 | } | |
| 296 | ||
| 297 | getRecentFiles().add( file.getAbsolutePath() ); | |
| 298 | } | |
| 299 | ||
| 300 | /** | |
| 301 | * Opens a new text editor document using the default document file name. | |
| 302 | */ | |
| 303 | public void newTextEditor() { | |
| 304 | open( DOCUMENT_DEFAULT ); | |
| 305 | } | |
| 306 | ||
| 307 | /** | |
| 308 | * Opens a new definition editor document using the default definition | |
| 309 | * file name. | |
| 310 | */ | |
| 311 | public void newDefinitionEditor() { | |
| 312 | open( DEFINITION_DEFAULT ); | |
| 313 | } | |
| 314 | ||
| 315 | /** | |
| 316 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 317 | * that they save themselves. | |
| 318 | */ | |
| 319 | public void saveAll() { | |
| 320 | mTabPanes.forEach( | |
| 321 | ( mt, tp ) -> tp.getTabs().forEach( ( tab ) -> { | |
| 322 | final var node = tab.getContent(); | |
| 323 | if( node instanceof TextEditor ) { | |
| 324 | save( ((TextEditor) node) ); | |
| 325 | } | |
| 326 | } ) | |
| 327 | ); | |
| 328 | } | |
| 329 | ||
| 330 | /** | |
| 331 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 332 | * checking if modified first because if the user swaps external media from | |
| 333 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 334 | * the user: save always re-saves. Also, it's less code. | |
| 335 | */ | |
| 336 | public void save() { | |
| 337 | save( getActiveTextEditor() ); | |
| 338 | } | |
| 339 | ||
| 340 | /** | |
| 341 | * Saves the active {@link TextEditor} under a new name. | |
| 342 | * | |
| 343 | * @param file The new active editor {@link File} reference. | |
| 344 | */ | |
| 345 | public void saveAs( final File file ) { | |
| 346 | assert file != null; | |
| 347 | final var editor = getActiveTextEditor(); | |
| 348 | final var tab = getTab( editor ); | |
| 349 | ||
| 350 | editor.rename( file ); | |
| 351 | tab.ifPresent( t -> { | |
| 352 | t.setText( editor.getFilename() ); | |
| 353 | t.setTooltip( createTooltip( file ) ); | |
| 354 | } ); | |
| 355 | ||
| 356 | save(); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 361 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 362 | * | |
| 363 | * @param resource The resource to export. | |
| 364 | */ | |
| 365 | private void save( final TextResource resource ) { | |
| 366 | try { | |
| 367 | resource.save(); | |
| 368 | } catch( final Exception ex ) { | |
| 369 | clue( ex ); | |
| 370 | sNotifier.alert( | |
| 371 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 372 | ); | |
| 373 | } | |
| 374 | } | |
| 375 | ||
| 376 | /** | |
| 377 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 378 | * | |
| 379 | * @return {@code true} when all editors, modified or otherwise, were | |
| 380 | * permitted to close; {@code false} when one or more editors were modified | |
| 381 | * and the user requested no closing. | |
| 382 | */ | |
| 383 | public boolean closeAll() { | |
| 384 | var closable = true; | |
| 385 | ||
| 386 | for( final var entry : mTabPanes.entrySet() ) { | |
| 387 | final var tabPane = entry.getValue(); | |
| 388 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 389 | ||
| 390 | while( tabIterator.hasNext() ) { | |
| 391 | final var tab = tabIterator.next(); | |
| 392 | final var resource = tab.getContent(); | |
| 393 | ||
| 394 | // The definition panes auto-save, so being specific here prevents | |
| 395 | // closing the definitions in the situation where the user wants to | |
| 396 | // continue editing (i.e., possibly save unsaved work). | |
| 397 | if( !(resource instanceof TextEditor) ) { | |
| 398 | continue; | |
| 399 | } | |
| 400 | ||
| 401 | if( canClose( (TextEditor) resource ) ) { | |
| 402 | tabIterator.remove(); | |
| 403 | close( tab ); | |
| 404 | } | |
| 405 | else { | |
| 406 | closable = false; | |
| 407 | } | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | return closable; | |
| 412 | } | |
| 413 | ||
| 414 | /** | |
| 415 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 416 | * event. | |
| 417 | * | |
| 418 | * @param tab The {@link Tab} that was closed. | |
| 419 | */ | |
| 420 | private void close( final Tab tab ) { | |
| 421 | final var handler = tab.getOnClosed(); | |
| 422 | ||
| 423 | if( handler != null ) { | |
| 424 | handler.handle( new ActionEvent() ); | |
| 425 | } | |
| 426 | } | |
| 427 | ||
| 428 | /** | |
| 429 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 430 | */ | |
| 431 | public void close() { | |
| 432 | final var editor = getActiveTextEditor(); | |
| 433 | if( canClose( editor ) ) { | |
| 434 | close( editor ); | |
| 435 | } | |
| 436 | } | |
| 437 | ||
| 438 | /** | |
| 439 | * Closes the given {@link TextResource}. This must not be called from within | |
| 440 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 441 | * concurrent modification exception be thrown. | |
| 442 | * | |
| 443 | * @param resource The {@link TextResource} to close, without confirming with | |
| 444 | * the user. | |
| 445 | */ | |
| 446 | private void close( final TextResource resource ) { | |
| 447 | getTab( resource ).ifPresent( | |
| 448 | ( tab ) -> { | |
| 449 | tab.getTabPane().getTabs().remove( tab ); | |
| 450 | close( tab ); | |
| 451 | } | |
| 452 | ); | |
| 453 | } | |
| 454 | ||
| 455 | /** | |
| 456 | * Answers whether the given {@link TextResource} may be closed. | |
| 457 | * | |
| 458 | * @param editor The {@link TextResource} to try closing. | |
| 459 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 460 | * the user has requested to keep the editor open. | |
| 461 | */ | |
| 462 | private boolean canClose( final TextResource editor ) { | |
| 463 | final var editorTab = getTab( editor ); | |
| 464 | final var canClose = new AtomicBoolean( true ); | |
| 465 | ||
| 466 | if( editor.isModified() ) { | |
| 467 | final var filename = new StringBuilder(); | |
| 468 | editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) ); | |
| 469 | ||
| 470 | final var message = sNotifier.createNotification( | |
| 471 | Messages.get( "Alert.file.close.title" ), | |
| 472 | Messages.get( "Alert.file.close.text" ), | |
| 473 | filename.toString() | |
| 474 | ); | |
| 475 | ||
| 476 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 477 | ||
| 478 | dialog.showAndWait().ifPresent( | |
| 479 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 480 | ); | |
| 481 | } | |
| 482 | ||
| 483 | return canClose.get(); | |
| 484 | } | |
| 485 | ||
| 486 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 487 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 488 | ||
| 489 | editor.addListener( ( c, o, n ) -> { | |
| 490 | if( n != null ) { | |
| 491 | mPreview.setBaseUri( n.getPath() ); | |
| 492 | process( n ); | |
| 493 | } | |
| 494 | } ); | |
| 495 | ||
| 496 | return editor; | |
| 497 | } | |
| 498 | ||
| 499 | /** | |
| 500 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 501 | */ | |
| 502 | public void viewPreview() { | |
| 503 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 504 | } | |
| 505 | ||
| 506 | /** | |
| 507 | * Adds the document outline tab to its own, singular tab pane. | |
| 508 | */ | |
| 509 | public void viewOutline() { | |
| 510 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 511 | } | |
| 512 | ||
| 513 | public void viewStatistics() { | |
| 514 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 515 | } | |
| 516 | ||
| 517 | private void viewTab( | |
| 518 | final Node node, final MediaType mediaType, final String key ) { | |
| 519 | final var tabPane = obtainTabPane( mediaType ); | |
| 520 | ||
| 521 | for( final var tab : tabPane.getTabs() ) { | |
| 522 | if( tab.getContent() == node ) { | |
| 523 | return; | |
| 524 | } | |
| 525 | } | |
| 526 | ||
| 527 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 520 | 528 | addTabPane( tabPane ); |
| 521 | 529 | } |
| 21 | 21 | import static com.keenwrite.events.StatusEvent.clue; |
| 22 | 22 | import static com.keenwrite.preferences.ThemeProperty.toFilename; |
| 23 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_CUSTOM; | |
| 24 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_THEME_SELECTION; | |
| 23 | import static com.keenwrite.preferences.WorkspaceKeys.*; | |
| 25 | 24 | import static com.keenwrite.ui.actions.ApplicationBars.*; |
| 26 | 25 | import static javafx.application.Platform.runLater; |
| 28 | 28 | import java.util.regex.Pattern; |
| 29 | 29 | |
| 30 | import static com.keenwrite.Constants.DEFINITION_DEFAULT; | |
| 31 | import static com.keenwrite.Messages.get; | |
| 32 | import static com.keenwrite.events.StatusEvent.clue; | |
| 33 | import static com.keenwrite.events.TextDefinitionFocusEvent.fireTextDefinitionFocus; | |
| 34 | import static java.lang.String.format; | |
| 35 | import static java.util.regex.Pattern.compile; | |
| 36 | import static java.util.regex.Pattern.quote; | |
| 37 | import static javafx.geometry.Pos.CENTER; | |
| 38 | import static javafx.geometry.Pos.TOP_CENTER; | |
| 39 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 40 | import static javafx.scene.control.TreeItem.childrenModificationEvent; | |
| 41 | import static javafx.scene.control.TreeItem.valueChangedEvent; | |
| 42 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 43 | ||
| 44 | /** | |
| 45 | * Provides the user interface that holds a {@link TreeView}, which | |
| 46 | * allows users to interact with key/value pairs loaded from the | |
| 47 | * document parser and adapted using a {@link TreeTransformer}. | |
| 48 | */ | |
| 49 | public final class DefinitionEditor extends BorderPane | |
| 50 | implements TextDefinition { | |
| 51 | private static final int GROUP_DELIMITED = 1; | |
| 52 | ||
| 53 | /** | |
| 54 | * Contains the root that is added to the view. | |
| 55 | */ | |
| 56 | private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem(); | |
| 57 | ||
| 58 | /** | |
| 59 | * Converts a tree item value to and from a string.. | |
| 60 | */ | |
| 61 | private final TreeItemConverter mConverter = new TreeItemConverter(); | |
| 62 | ||
| 63 | /** | |
| 64 | * Contains a view of the definitions. | |
| 65 | */ | |
| 66 | private final TreeView<String> mTreeView = | |
| 67 | new AltTreeView<>( mTreeRoot, mConverter ); | |
| 68 | ||
| 69 | /** | |
| 70 | * Used to adapt the structured document into a {@link TreeView}. | |
| 71 | */ | |
| 72 | private final TreeTransformer mTreeTransformer; | |
| 73 | ||
| 74 | /** | |
| 75 | * Handlers for key press events. | |
| 76 | */ | |
| 77 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 78 | = new HashSet<>(); | |
| 79 | ||
| 80 | /** | |
| 81 | * File being edited by this editor instance. | |
| 82 | */ | |
| 83 | private File mFile; | |
| 84 | ||
| 85 | /** | |
| 86 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 87 | * either no encoding could be determined or this is a new (empty) file. | |
| 88 | */ | |
| 89 | private final Charset mEncoding; | |
| 90 | ||
| 91 | /** | |
| 92 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 93 | * persisted definitions. | |
| 94 | */ | |
| 95 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 96 | ||
| 97 | /** | |
| 98 | * This is provided for unit tests that are not backed by files. | |
| 99 | * | |
| 100 | * @param treeTransformer Responsible for transforming the definitions into | |
| 101 | * {@link TreeItem} instances. | |
| 102 | */ | |
| 103 | public DefinitionEditor( | |
| 104 | final TreeTransformer treeTransformer ) { | |
| 105 | this( DEFINITION_DEFAULT, treeTransformer ); | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Constructs a definition pane with a given tree view root. | |
| 110 | * | |
| 111 | * @param file The file of definitions to maintain through the UI. | |
| 112 | */ | |
| 113 | public DefinitionEditor( | |
| 114 | final File file, | |
| 115 | final TreeTransformer treeTransformer ) { | |
| 116 | assert file != null; | |
| 117 | assert treeTransformer != null; | |
| 118 | ||
| 119 | mFile = file; | |
| 120 | mTreeTransformer = treeTransformer; | |
| 121 | ||
| 122 | //mTreeView.setCellFactory( new TreeCellFactory() ); | |
| 123 | mTreeView.setContextMenu( createContextMenu() ); | |
| 124 | mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 125 | mTreeView.focusedProperty().addListener( this::focused ); | |
| 126 | getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 127 | ||
| 128 | final var buttonBar = new HBox(); | |
| 129 | buttonBar.getChildren().addAll( | |
| 130 | createButton( "create", e -> createDefinition() ), | |
| 131 | createButton( "rename", e -> renameDefinition() ), | |
| 132 | createButton( "delete", e -> deleteDefinitions() ) | |
| 133 | ); | |
| 134 | buttonBar.setAlignment( CENTER ); | |
| 135 | buttonBar.setSpacing( 10 ); | |
| 136 | ||
| 137 | setTop( buttonBar ); | |
| 138 | setCenter( mTreeView ); | |
| 139 | setAlignment( buttonBar, TOP_CENTER ); | |
| 140 | mEncoding = open( mFile ); | |
| 141 | ||
| 142 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 143 | // upon saving, users will be prompted to save a file that hasn't had | |
| 144 | // any modifications (from their perspective). | |
| 145 | addTreeChangeHandler( event -> mModified.set( true ) ); | |
| 146 | } | |
| 147 | ||
| 148 | @Override | |
| 149 | public void setText( final String document ) { | |
| 150 | final var foster = mTreeTransformer.transform( document ); | |
| 151 | final var biological = getTreeRoot(); | |
| 152 | ||
| 153 | for( final var child : foster.getChildren() ) { | |
| 154 | biological.getChildren().add( child ); | |
| 155 | } | |
| 156 | ||
| 157 | getTreeView().refresh(); | |
| 158 | } | |
| 159 | ||
| 160 | @Override | |
| 161 | public String getText() { | |
| 162 | final var result = new StringBuilder( 32768 ); | |
| 163 | ||
| 164 | try { | |
| 165 | final var root = getTreeView().getRoot(); | |
| 166 | final var problem = isTreeWellFormed(); | |
| 167 | ||
| 168 | problem.ifPresentOrElse( | |
| 169 | ( node ) -> clue( "yaml.error.tree.form", node ), | |
| 170 | () -> result.append( mTreeTransformer.transform( root ) ) | |
| 171 | ); | |
| 172 | } catch( final Exception ex ) { | |
| 173 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 174 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 175 | clue( ex ); | |
| 176 | } | |
| 177 | ||
| 178 | return result.toString(); | |
| 179 | } | |
| 180 | ||
| 181 | @Override | |
| 182 | public File getFile() { | |
| 183 | return mFile; | |
| 184 | } | |
| 185 | ||
| 186 | @Override | |
| 187 | public void rename( final File file ) { | |
| 188 | mFile = file; | |
| 189 | } | |
| 190 | ||
| 191 | @Override | |
| 192 | public Charset getEncoding() { | |
| 193 | return mEncoding; | |
| 194 | } | |
| 195 | ||
| 196 | @Override | |
| 197 | public Node getNode() { | |
| 198 | return this; | |
| 199 | } | |
| 200 | ||
| 201 | @Override | |
| 202 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 203 | return mModified; | |
| 204 | } | |
| 205 | ||
| 206 | @Override | |
| 207 | public void clearModifiedProperty() { | |
| 208 | mModified.setValue( false ); | |
| 209 | } | |
| 210 | ||
| 211 | private Button createButton( | |
| 212 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 213 | final var keyPrefix = "App.action.definition." + msgKey; | |
| 214 | final var button = new Button( get( keyPrefix + ".text" ) ); | |
| 215 | final var icon = get( keyPrefix + ".icon" ); | |
| 216 | final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() ); | |
| 217 | ||
| 218 | button.setOnAction( eventHandler ); | |
| 219 | button.setGraphic( | |
| 220 | FontAwesomeIconFactory.get().createIcon( glyph ) | |
| 221 | ); | |
| 222 | button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | |
| 223 | ||
| 224 | return button; | |
| 225 | } | |
| 226 | ||
| 227 | @Override | |
| 228 | public Map<String, String> toMap() { | |
| 229 | return new TreeItemMapper().toMap( getTreeView().getRoot() ); | |
| 230 | } | |
| 231 | ||
| 232 | @Override | |
| 233 | public Map<String, String> interpolate( | |
| 234 | final Map<String, String> map, final Tokens tokens ) { | |
| 235 | ||
| 236 | // Non-greedy match of key names delimited by definition tokens. | |
| 237 | final var pattern = compile( | |
| 238 | format( "(%s.*?%s)", | |
| 239 | quote( tokens.getBegan() ), | |
| 240 | quote( tokens.getEnded() ) | |
| 241 | ) | |
| 242 | ); | |
| 243 | ||
| 244 | map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) ); | |
| 245 | return map; | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * Given a value with zero or more key references, this will resolve all | |
| 250 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 251 | * contain the key name. | |
| 252 | * | |
| 253 | * @param map Map to search for keys when resolving key references. | |
| 254 | * @param value Value containing zero or more key references. | |
| 255 | * @param pattern The regular expression pattern to match variable key names. | |
| 256 | * @return The given value with all embedded key references interpolated. | |
| 257 | */ | |
| 258 | private String resolve( | |
| 259 | final Map<String, String> map, String value, final Pattern pattern ) { | |
| 260 | final var matcher = pattern.matcher( value ); | |
| 261 | ||
| 262 | while( matcher.find() ) { | |
| 263 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 264 | final var mapValue = map.get( keyName ); | |
| 265 | final var keyValue = mapValue == null | |
| 266 | ? keyName | |
| 267 | : resolve( map, mapValue, pattern ); | |
| 268 | ||
| 269 | value = value.replace( keyName, keyValue ); | |
| 270 | } | |
| 271 | ||
| 272 | return value; | |
| 273 | } | |
| 274 | ||
| 275 | ||
| 276 | /** | |
| 277 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 278 | * is modified. The modifications include: item value changes, item additions, | |
| 279 | * and item removals. | |
| 280 | * <p> | |
| 281 | * Safe to call multiple times; if a handler is already registered, the | |
| 282 | * old handler is used. | |
| 283 | * </p> | |
| 284 | * | |
| 285 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 286 | */ | |
| 287 | public void addTreeChangeHandler( | |
| 288 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 289 | final var root = getTreeView().getRoot(); | |
| 290 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 291 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 292 | } | |
| 293 | ||
| 294 | /** | |
| 295 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 296 | * well-formed for export. A tree is considered well-formed if the following | |
| 297 | * conditions are met: | |
| 298 | * | |
| 299 | * <ul> | |
| 300 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 301 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 302 | * </ul> | |
| 303 | * | |
| 304 | * @return {@code null} if the document is well-formed, otherwise the | |
| 305 | * problematic child {@link TreeItem}. | |
| 306 | */ | |
| 307 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 308 | final var root = getTreeView().getRoot(); | |
| 309 | ||
| 310 | for( final var child : root.getChildren() ) { | |
| 311 | final var problemChild = isWellFormed( child ); | |
| 312 | ||
| 313 | if( child.isLeaf() || problemChild != null ) { | |
| 314 | return Optional.ofNullable( problemChild ); | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| 318 | return Optional.empty(); | |
| 319 | } | |
| 320 | ||
| 321 | /** | |
| 322 | * Determines whether the document is well-formed by ensuring that | |
| 323 | * child branches do not contain multiple leaves. | |
| 324 | * | |
| 325 | * @param item The sub-tree to check for well-formedness. | |
| 326 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 327 | * problematic {@link TreeItem}. | |
| 328 | */ | |
| 329 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 330 | int childLeafs = 0; | |
| 331 | int childBranches = 0; | |
| 332 | ||
| 333 | for( final var child : item.getChildren() ) { | |
| 334 | if( child.isLeaf() ) { | |
| 335 | childLeafs++; | |
| 336 | } | |
| 337 | else { | |
| 338 | childBranches++; | |
| 339 | } | |
| 340 | ||
| 341 | final var problemChild = isWellFormed( child ); | |
| 342 | ||
| 343 | if( problemChild != null ) { | |
| 344 | return problemChild; | |
| 345 | } | |
| 346 | } | |
| 347 | ||
| 348 | return ((childBranches > 0 && childLeafs == 0) || | |
| 349 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 350 | } | |
| 351 | ||
| 352 | @Override | |
| 353 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 354 | return getTreeRoot().findLeafExact( text ); | |
| 355 | } | |
| 356 | ||
| 357 | @Override | |
| 358 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 359 | return getTreeRoot().findLeafContains( text ); | |
| 360 | } | |
| 361 | ||
| 362 | @Override | |
| 363 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 364 | final String text ) { | |
| 365 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 366 | } | |
| 367 | ||
| 368 | @Override | |
| 369 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 370 | return getTreeRoot().findLeafStartsWith( text ); | |
| 371 | } | |
| 372 | ||
| 373 | public void select( final TreeItem<String> item ) { | |
| 374 | getSelectionModel().clearSelection(); | |
| 375 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 376 | } | |
| 377 | ||
| 378 | /** | |
| 379 | * Collapses the tree, recursively. | |
| 380 | */ | |
| 381 | public void collapse() { | |
| 382 | collapse( getTreeRoot().getChildren() ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Collapses the tree, recursively. | |
| 387 | * | |
| 388 | * @param <T> The type of tree item to expand (usually String). | |
| 389 | * @param nodes The nodes to collapse. | |
| 390 | */ | |
| 391 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 392 | for( final var node : nodes ) { | |
| 393 | node.setExpanded( false ); | |
| 394 | collapse( node.getChildren() ); | |
| 395 | } | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 400 | */ | |
| 401 | private boolean isEditingTreeItem() { | |
| 402 | return getTreeView().editingItemProperty().getValue() != null; | |
| 403 | } | |
| 404 | ||
| 405 | /** | |
| 406 | * Changes to edit mode for the selected item. | |
| 407 | */ | |
| 408 | @Override | |
| 409 | public void renameDefinition() { | |
| 410 | getTreeView().edit( getSelectedItem() ); | |
| 411 | } | |
| 412 | ||
| 413 | /** | |
| 414 | * Removes all selected items from the {@link TreeView}. | |
| 415 | */ | |
| 416 | @Override | |
| 417 | public void deleteDefinitions() { | |
| 418 | for( final var item : getSelectedItems() ) { | |
| 419 | final var parent = item.getParent(); | |
| 420 | ||
| 421 | if( parent != null ) { | |
| 422 | parent.getChildren().remove( item ); | |
| 423 | } | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * Deletes the selected item. | |
| 429 | */ | |
| 430 | private void deleteSelectedItem() { | |
| 431 | final var c = getSelectedItem(); | |
| 432 | getSiblings( c ).remove( c ); | |
| 433 | } | |
| 434 | ||
| 435 | /** | |
| 436 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 437 | * There are a few conditions to consider: when adding to the root, | |
| 438 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 439 | * root must contain two items: a key and a value. | |
| 440 | */ | |
| 441 | @Override | |
| 442 | public void createDefinition() { | |
| 443 | final var value = createDefinitionTreeItem(); | |
| 444 | getSelectedItem().getChildren().add( value ); | |
| 445 | expand( value ); | |
| 446 | select( value ); | |
| 447 | } | |
| 448 | ||
| 449 | private ContextMenu createContextMenu() { | |
| 450 | final var menu = new ContextMenu(); | |
| 451 | final var items = menu.getItems(); | |
| 452 | ||
| 453 | addMenuItem( items, "App.action.definition.create.text" ) | |
| 454 | .setOnAction( e -> createDefinition() ); | |
| 455 | addMenuItem( items, "App.action.definition.rename.text" ) | |
| 456 | .setOnAction( e -> renameDefinition() ); | |
| 457 | addMenuItem( items, "App.action.definition.delete.text" ) | |
| 30 | import static com.keenwrite.Constants.ACTION_PREFIX; | |
| 31 | import static com.keenwrite.Constants.DEFINITION_DEFAULT; | |
| 32 | import static com.keenwrite.Messages.get; | |
| 33 | import static com.keenwrite.events.StatusEvent.clue; | |
| 34 | import static com.keenwrite.events.TextDefinitionFocusEvent.fireTextDefinitionFocus; | |
| 35 | import static java.lang.String.format; | |
| 36 | import static java.util.regex.Pattern.compile; | |
| 37 | import static java.util.regex.Pattern.quote; | |
| 38 | import static javafx.geometry.Pos.CENTER; | |
| 39 | import static javafx.geometry.Pos.TOP_CENTER; | |
| 40 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 41 | import static javafx.scene.control.TreeItem.childrenModificationEvent; | |
| 42 | import static javafx.scene.control.TreeItem.valueChangedEvent; | |
| 43 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 44 | ||
| 45 | /** | |
| 46 | * Provides the user interface that holds a {@link TreeView}, which | |
| 47 | * allows users to interact with key/value pairs loaded from the | |
| 48 | * document parser and adapted using a {@link TreeTransformer}. | |
| 49 | */ | |
| 50 | public final class DefinitionEditor extends BorderPane | |
| 51 | implements TextDefinition { | |
| 52 | private static final int GROUP_DELIMITED = 1; | |
| 53 | ||
| 54 | /** | |
| 55 | * Contains the root that is added to the view. | |
| 56 | */ | |
| 57 | private final DefinitionTreeItem<String> mTreeRoot = createRootTreeItem(); | |
| 58 | ||
| 59 | /** | |
| 60 | * Contains a view of the definitions. | |
| 61 | */ | |
| 62 | private final TreeView<String> mTreeView = | |
| 63 | new AltTreeView<>( mTreeRoot, new TreeItemConverter() ); | |
| 64 | ||
| 65 | /** | |
| 66 | * Used to adapt the structured document into a {@link TreeView}. | |
| 67 | */ | |
| 68 | private final TreeTransformer mTreeTransformer; | |
| 69 | ||
| 70 | /** | |
| 71 | * Handlers for key press events. | |
| 72 | */ | |
| 73 | private final Set<EventHandler<? super KeyEvent>> mKeyEventHandlers | |
| 74 | = new HashSet<>(); | |
| 75 | ||
| 76 | /** | |
| 77 | * File being edited by this editor instance. | |
| 78 | */ | |
| 79 | private File mFile; | |
| 80 | ||
| 81 | /** | |
| 82 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 83 | * either no encoding could be determined or this is a new (empty) file. | |
| 84 | */ | |
| 85 | private final Charset mEncoding; | |
| 86 | ||
| 87 | /** | |
| 88 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 89 | * persisted definitions. | |
| 90 | */ | |
| 91 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 92 | ||
| 93 | /** | |
| 94 | * This is provided for unit tests that are not backed by files. | |
| 95 | * | |
| 96 | * @param treeTransformer Responsible for transforming the definitions into | |
| 97 | * {@link TreeItem} instances. | |
| 98 | */ | |
| 99 | public DefinitionEditor( | |
| 100 | final TreeTransformer treeTransformer ) { | |
| 101 | this( DEFINITION_DEFAULT, treeTransformer ); | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Constructs a definition pane with a given tree view root. | |
| 106 | * | |
| 107 | * @param file The file of definitions to maintain through the UI. | |
| 108 | */ | |
| 109 | public DefinitionEditor( | |
| 110 | final File file, | |
| 111 | final TreeTransformer treeTransformer ) { | |
| 112 | assert file != null; | |
| 113 | assert treeTransformer != null; | |
| 114 | ||
| 115 | mFile = file; | |
| 116 | mTreeTransformer = treeTransformer; | |
| 117 | ||
| 118 | //mTreeView.setCellFactory( new TreeCellFactory() ); | |
| 119 | mTreeView.setContextMenu( createContextMenu() ); | |
| 120 | mTreeView.addEventFilter( KEY_PRESSED, this::keyEventFilter ); | |
| 121 | mTreeView.focusedProperty().addListener( this::focused ); | |
| 122 | getSelectionModel().setSelectionMode( MULTIPLE ); | |
| 123 | ||
| 124 | final var buttonBar = new HBox(); | |
| 125 | buttonBar.getChildren().addAll( | |
| 126 | createButton( "create", e -> createDefinition() ), | |
| 127 | createButton( "rename", e -> renameDefinition() ), | |
| 128 | createButton( "delete", e -> deleteDefinitions() ) | |
| 129 | ); | |
| 130 | buttonBar.setAlignment( CENTER ); | |
| 131 | buttonBar.setSpacing( 10 ); | |
| 132 | ||
| 133 | setTop( buttonBar ); | |
| 134 | setCenter( mTreeView ); | |
| 135 | setAlignment( buttonBar, TOP_CENTER ); | |
| 136 | mEncoding = open( mFile ); | |
| 137 | ||
| 138 | // After the file is opened, watch for changes, not before. Otherwise, | |
| 139 | // upon saving, users will be prompted to save a file that hasn't had | |
| 140 | // any modifications (from their perspective). | |
| 141 | addTreeChangeHandler( event -> mModified.set( true ) ); | |
| 142 | } | |
| 143 | ||
| 144 | @Override | |
| 145 | public void setText( final String document ) { | |
| 146 | final var foster = mTreeTransformer.transform( document ); | |
| 147 | final var biological = getTreeRoot(); | |
| 148 | ||
| 149 | for( final var child : foster.getChildren() ) { | |
| 150 | biological.getChildren().add( child ); | |
| 151 | } | |
| 152 | ||
| 153 | getTreeView().refresh(); | |
| 154 | } | |
| 155 | ||
| 156 | @Override | |
| 157 | public String getText() { | |
| 158 | final var result = new StringBuilder( 32768 ); | |
| 159 | ||
| 160 | try { | |
| 161 | final var root = getTreeView().getRoot(); | |
| 162 | final var problem = isTreeWellFormed(); | |
| 163 | ||
| 164 | problem.ifPresentOrElse( | |
| 165 | ( node ) -> clue( "yaml.error.tree.form", node ), | |
| 166 | () -> result.append( mTreeTransformer.transform( root ) ) | |
| 167 | ); | |
| 168 | } catch( final Exception ex ) { | |
| 169 | // Catch errors while checking for a well-formed tree (e.g., stack smash). | |
| 170 | // Also catch any transformation exceptions (e.g., Json processing). | |
| 171 | clue( ex ); | |
| 172 | } | |
| 173 | ||
| 174 | return result.toString(); | |
| 175 | } | |
| 176 | ||
| 177 | @Override | |
| 178 | public File getFile() { | |
| 179 | return mFile; | |
| 180 | } | |
| 181 | ||
| 182 | @Override | |
| 183 | public void rename( final File file ) { | |
| 184 | mFile = file; | |
| 185 | } | |
| 186 | ||
| 187 | @Override | |
| 188 | public Charset getEncoding() { | |
| 189 | return mEncoding; | |
| 190 | } | |
| 191 | ||
| 192 | @Override | |
| 193 | public Node getNode() { | |
| 194 | return this; | |
| 195 | } | |
| 196 | ||
| 197 | @Override | |
| 198 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 199 | return mModified; | |
| 200 | } | |
| 201 | ||
| 202 | @Override | |
| 203 | public void clearModifiedProperty() { | |
| 204 | mModified.setValue( false ); | |
| 205 | } | |
| 206 | ||
| 207 | private Button createButton( | |
| 208 | final String msgKey, final EventHandler<ActionEvent> eventHandler ) { | |
| 209 | final var keyPrefix = Constants.ACTION_PREFIX + "definition." + msgKey; | |
| 210 | final var button = new Button( get( keyPrefix + ".text" ) ); | |
| 211 | final var icon = get( keyPrefix + ".icon" ); | |
| 212 | final var glyph = FontAwesomeIcon.valueOf( icon.toUpperCase() ); | |
| 213 | ||
| 214 | button.setOnAction( eventHandler ); | |
| 215 | button.setGraphic( | |
| 216 | FontAwesomeIconFactory.get().createIcon( glyph ) | |
| 217 | ); | |
| 218 | button.setTooltip( new Tooltip( get( keyPrefix + ".tooltip" ) ) ); | |
| 219 | ||
| 220 | return button; | |
| 221 | } | |
| 222 | ||
| 223 | @Override | |
| 224 | public Map<String, String> toMap() { | |
| 225 | return new TreeItemMapper().toMap( getTreeView().getRoot() ); | |
| 226 | } | |
| 227 | ||
| 228 | @Override | |
| 229 | public Map<String, String> interpolate( | |
| 230 | final Map<String, String> map, final Tokens tokens ) { | |
| 231 | ||
| 232 | // Non-greedy match of key names delimited by definition tokens. | |
| 233 | final var pattern = compile( | |
| 234 | format( "(%s.*?%s)", | |
| 235 | quote( tokens.getBegan() ), | |
| 236 | quote( tokens.getEnded() ) | |
| 237 | ) | |
| 238 | ); | |
| 239 | ||
| 240 | map.replaceAll( ( k, v ) -> resolve( map, v, pattern ) ); | |
| 241 | return map; | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Given a value with zero or more key references, this will resolve all | |
| 246 | * the values, recursively. If a key cannot be de-referenced, the value will | |
| 247 | * contain the key name. | |
| 248 | * | |
| 249 | * @param map Map to search for keys when resolving key references. | |
| 250 | * @param value Value containing zero or more key references. | |
| 251 | * @param pattern The regular expression pattern to match variable key names. | |
| 252 | * @return The given value with all embedded key references interpolated. | |
| 253 | */ | |
| 254 | private String resolve( | |
| 255 | final Map<String, String> map, String value, final Pattern pattern ) { | |
| 256 | final var matcher = pattern.matcher( value ); | |
| 257 | ||
| 258 | while( matcher.find() ) { | |
| 259 | final var keyName = matcher.group( GROUP_DELIMITED ); | |
| 260 | final var mapValue = map.get( keyName ); | |
| 261 | final var keyValue = mapValue == null | |
| 262 | ? keyName | |
| 263 | : resolve( map, mapValue, pattern ); | |
| 264 | ||
| 265 | value = value.replace( keyName, keyValue ); | |
| 266 | } | |
| 267 | ||
| 268 | return value; | |
| 269 | } | |
| 270 | ||
| 271 | ||
| 272 | /** | |
| 273 | * Informs the caller of whenever any {@link TreeItem} in the {@link TreeView} | |
| 274 | * is modified. The modifications include: item value changes, item additions, | |
| 275 | * and item removals. | |
| 276 | * <p> | |
| 277 | * Safe to call multiple times; if a handler is already registered, the | |
| 278 | * old handler is used. | |
| 279 | * </p> | |
| 280 | * | |
| 281 | * @param handler The handler to call whenever any {@link TreeItem} changes. | |
| 282 | */ | |
| 283 | public void addTreeChangeHandler( | |
| 284 | final EventHandler<TreeItem.TreeModificationEvent<Event>> handler ) { | |
| 285 | final var root = getTreeView().getRoot(); | |
| 286 | root.addEventHandler( valueChangedEvent(), handler ); | |
| 287 | root.addEventHandler( childrenModificationEvent(), handler ); | |
| 288 | } | |
| 289 | ||
| 290 | /** | |
| 291 | * Answers whether the {@link TreeItem}s in the {@link TreeView} are suitably | |
| 292 | * well-formed for export. A tree is considered well-formed if the following | |
| 293 | * conditions are met: | |
| 294 | * | |
| 295 | * <ul> | |
| 296 | * <li>The root node contains at least one child node having a leaf.</li> | |
| 297 | * <li>There are no leaf nodes with sibling leaf nodes.</li> | |
| 298 | * </ul> | |
| 299 | * | |
| 300 | * @return {@code null} if the document is well-formed, otherwise the | |
| 301 | * problematic child {@link TreeItem}. | |
| 302 | */ | |
| 303 | public Optional<TreeItem<String>> isTreeWellFormed() { | |
| 304 | final var root = getTreeView().getRoot(); | |
| 305 | ||
| 306 | for( final var child : root.getChildren() ) { | |
| 307 | final var problemChild = isWellFormed( child ); | |
| 308 | ||
| 309 | if( child.isLeaf() || problemChild != null ) { | |
| 310 | return Optional.ofNullable( problemChild ); | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | return Optional.empty(); | |
| 315 | } | |
| 316 | ||
| 317 | /** | |
| 318 | * Determines whether the document is well-formed by ensuring that | |
| 319 | * child branches do not contain multiple leaves. | |
| 320 | * | |
| 321 | * @param item The sub-tree to check for well-formedness. | |
| 322 | * @return {@code null} when the tree is well-formed, otherwise the | |
| 323 | * problematic {@link TreeItem}. | |
| 324 | */ | |
| 325 | private TreeItem<String> isWellFormed( final TreeItem<String> item ) { | |
| 326 | int childLeafs = 0; | |
| 327 | int childBranches = 0; | |
| 328 | ||
| 329 | for( final var child : item.getChildren() ) { | |
| 330 | if( child.isLeaf() ) { | |
| 331 | childLeafs++; | |
| 332 | } | |
| 333 | else { | |
| 334 | childBranches++; | |
| 335 | } | |
| 336 | ||
| 337 | final var problemChild = isWellFormed( child ); | |
| 338 | ||
| 339 | if( problemChild != null ) { | |
| 340 | return problemChild; | |
| 341 | } | |
| 342 | } | |
| 343 | ||
| 344 | return ((childBranches > 0 && childLeafs == 0) || | |
| 345 | (childBranches == 0 && childLeafs <= 1)) ? null : item; | |
| 346 | } | |
| 347 | ||
| 348 | @Override | |
| 349 | public DefinitionTreeItem<String> findLeafExact( final String text ) { | |
| 350 | return getTreeRoot().findLeafExact( text ); | |
| 351 | } | |
| 352 | ||
| 353 | @Override | |
| 354 | public DefinitionTreeItem<String> findLeafContains( final String text ) { | |
| 355 | return getTreeRoot().findLeafContains( text ); | |
| 356 | } | |
| 357 | ||
| 358 | @Override | |
| 359 | public DefinitionTreeItem<String> findLeafContainsNoCase( | |
| 360 | final String text ) { | |
| 361 | return getTreeRoot().findLeafContainsNoCase( text ); | |
| 362 | } | |
| 363 | ||
| 364 | @Override | |
| 365 | public DefinitionTreeItem<String> findLeafStartsWith( final String text ) { | |
| 366 | return getTreeRoot().findLeafStartsWith( text ); | |
| 367 | } | |
| 368 | ||
| 369 | public void select( final TreeItem<String> item ) { | |
| 370 | getSelectionModel().clearSelection(); | |
| 371 | getSelectionModel().select( getTreeView().getRow( item ) ); | |
| 372 | } | |
| 373 | ||
| 374 | /** | |
| 375 | * Collapses the tree, recursively. | |
| 376 | */ | |
| 377 | public void collapse() { | |
| 378 | collapse( getTreeRoot().getChildren() ); | |
| 379 | } | |
| 380 | ||
| 381 | /** | |
| 382 | * Collapses the tree, recursively. | |
| 383 | * | |
| 384 | * @param <T> The type of tree item to expand (usually String). | |
| 385 | * @param nodes The nodes to collapse. | |
| 386 | */ | |
| 387 | private <T> void collapse( final ObservableList<TreeItem<T>> nodes ) { | |
| 388 | for( final var node : nodes ) { | |
| 389 | node.setExpanded( false ); | |
| 390 | collapse( node.getChildren() ); | |
| 391 | } | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * @return {@code true} when the user is editing a {@link TreeItem}. | |
| 396 | */ | |
| 397 | private boolean isEditingTreeItem() { | |
| 398 | return getTreeView().editingItemProperty().getValue() != null; | |
| 399 | } | |
| 400 | ||
| 401 | /** | |
| 402 | * Changes to edit mode for the selected item. | |
| 403 | */ | |
| 404 | @Override | |
| 405 | public void renameDefinition() { | |
| 406 | getTreeView().edit( getSelectedItem() ); | |
| 407 | } | |
| 408 | ||
| 409 | /** | |
| 410 | * Removes all selected items from the {@link TreeView}. | |
| 411 | */ | |
| 412 | @Override | |
| 413 | public void deleteDefinitions() { | |
| 414 | for( final var item : getSelectedItems() ) { | |
| 415 | final var parent = item.getParent(); | |
| 416 | ||
| 417 | if( parent != null ) { | |
| 418 | parent.getChildren().remove( item ); | |
| 419 | } | |
| 420 | } | |
| 421 | } | |
| 422 | ||
| 423 | /** | |
| 424 | * Deletes the selected item. | |
| 425 | */ | |
| 426 | private void deleteSelectedItem() { | |
| 427 | final var c = getSelectedItem(); | |
| 428 | getSiblings( c ).remove( c ); | |
| 429 | } | |
| 430 | ||
| 431 | /** | |
| 432 | * Adds a new item under the selected item (or root if nothing is selected). | |
| 433 | * There are a few conditions to consider: when adding to the root, | |
| 434 | * when adding to a leaf, and when adding to a non-leaf. Items added to the | |
| 435 | * root must contain two items: a key and a value. | |
| 436 | */ | |
| 437 | @Override | |
| 438 | public void createDefinition() { | |
| 439 | final var value = createDefinitionTreeItem(); | |
| 440 | getSelectedItem().getChildren().add( value ); | |
| 441 | expand( value ); | |
| 442 | select( value ); | |
| 443 | } | |
| 444 | ||
| 445 | private ContextMenu createContextMenu() { | |
| 446 | final var menu = new ContextMenu(); | |
| 447 | final var items = menu.getItems(); | |
| 448 | ||
| 449 | addMenuItem( items, ACTION_PREFIX + "definition.create.text" ) | |
| 450 | .setOnAction( e -> createDefinition() ); | |
| 451 | addMenuItem( items, ACTION_PREFIX + "definition.rename.text" ) | |
| 452 | .setOnAction( e -> renameDefinition() ); | |
| 453 | addMenuItem( items, ACTION_PREFIX + "definition.delete.text" ) | |
| 458 | 454 | .setOnAction( e -> deleteSelectedItem() ); |
| 459 | 455 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | * | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 14 | * | |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 26 | */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 27 | 2 | |
| 28 | 3 | /** |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | * | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 14 | * | |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 26 | */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 27 | 2 | |
| 28 | 3 | /** |
| 40 | 40 | import static java.lang.String.format; |
| 41 | 41 | import static java.util.Collections.singletonList; |
| 42 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 43 | import static javafx.scene.input.KeyCode.*; | |
| 44 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 45 | import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | |
| 46 | import static org.apache.commons.lang3.StringUtils.stripEnd; | |
| 47 | import static org.apache.commons.lang3.StringUtils.stripStart; | |
| 48 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 49 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 50 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 51 | ||
| 52 | /** | |
| 53 | * Responsible for editing Markdown documents. | |
| 54 | */ | |
| 55 | public final class MarkdownEditor extends BorderPane implements TextEditor { | |
| 56 | /** | |
| 57 | * Regular expression that matches the type of markup block. This is used | |
| 58 | * when Enter is pressed to continue the block environment. | |
| 59 | */ | |
| 60 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 61 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 62 | ||
| 63 | /** | |
| 64 | * The text editor. | |
| 65 | */ | |
| 66 | private final StyleClassedTextArea mTextArea = | |
| 67 | new StyleClassedTextArea( false ); | |
| 68 | ||
| 69 | /** | |
| 70 | * Wraps the text editor in scrollbars. | |
| 71 | */ | |
| 72 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 73 | new VirtualizedScrollPane<>( mTextArea ); | |
| 74 | ||
| 75 | private final Workspace mWorkspace; | |
| 76 | ||
| 77 | /** | |
| 78 | * Tracks where the caret is located in this document. This offers observable | |
| 79 | * properties for caret position changes. | |
| 80 | */ | |
| 81 | private final Caret mCaret = createCaret( mTextArea ); | |
| 82 | ||
| 83 | /** | |
| 84 | * File being edited by this editor instance. | |
| 85 | */ | |
| 86 | private File mFile; | |
| 87 | ||
| 88 | /** | |
| 89 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 90 | * false} by default. | |
| 91 | */ | |
| 92 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 93 | ||
| 94 | /** | |
| 95 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 96 | * either no encoding could be determined or this is a new (empty) file. | |
| 97 | */ | |
| 98 | private final Charset mEncoding; | |
| 99 | ||
| 100 | /** | |
| 101 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 102 | * persisted definitions. | |
| 103 | */ | |
| 104 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 105 | ||
| 106 | public MarkdownEditor( final Workspace workspace ) { | |
| 107 | this( DOCUMENT_DEFAULT, workspace ); | |
| 108 | } | |
| 109 | ||
| 110 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 111 | mEncoding = open( mFile = file ); | |
| 112 | mWorkspace = workspace; | |
| 113 | ||
| 114 | initTextArea( mTextArea ); | |
| 115 | initStyle( mTextArea ); | |
| 116 | initScrollPane( mScrollPane ); | |
| 117 | initSpellchecker( mTextArea ); | |
| 118 | initHotKeys(); | |
| 119 | initUndoManager(); | |
| 120 | } | |
| 121 | ||
| 122 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 123 | textArea.setWrapText( true ); | |
| 124 | textArea.requestFollowCaret(); | |
| 125 | textArea.moveTo( 0 ); | |
| 126 | ||
| 127 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 128 | // Fire, regardless of whether the caret position has changed. | |
| 129 | mDirty.set( false ); | |
| 130 | ||
| 131 | // Prevent a caret position change from raising the dirty bits. | |
| 132 | mDirty.set( true ); | |
| 133 | } ); | |
| 134 | ||
| 135 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 136 | // Fire when the caret position has changed and the text has not. | |
| 137 | mDirty.set( true ); | |
| 138 | mDirty.set( false ); | |
| 139 | } ); | |
| 140 | ||
| 141 | textArea.focusedProperty().addListener( ( c, o, n ) -> { | |
| 142 | if( n != null && n ) { | |
| 143 | fireTextEditorFocus( this ); | |
| 144 | } | |
| 145 | } ); | |
| 146 | } | |
| 147 | ||
| 148 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 149 | textArea.getStyleClass().add( "markdown" ); | |
| 150 | ||
| 151 | final var stylesheets = textArea.getStylesheets(); | |
| 152 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 153 | ||
| 154 | localeProperty().addListener( ( c, o, n ) -> { | |
| 155 | if( n != null ) { | |
| 156 | stylesheets.clear(); | |
| 157 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 158 | } | |
| 159 | } ); | |
| 160 | ||
| 161 | fontNameProperty().addListener( | |
| 162 | ( c, o, n ) -> | |
| 163 | mTextArea.setStyle( format( "-fx-font-family: '%s';", getFontName() ) ) | |
| 164 | ); | |
| 165 | ||
| 166 | fontSizeProperty().addListener( | |
| 167 | ( c, o, n ) -> | |
| 168 | mTextArea.setStyle( format( "-fx-font-size: %spt;", getFontSize() ) ) | |
| 169 | ); | |
| 170 | } | |
| 171 | ||
| 172 | private void initScrollPane( | |
| 173 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 174 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 175 | setCenter( scrollpane ); | |
| 176 | } | |
| 177 | ||
| 178 | private void initSpellchecker( final StyleClassedTextArea textarea ) { | |
| 179 | final var speller = new TextEditorSpeller(); | |
| 180 | speller.checkDocument( textarea ); | |
| 181 | speller.checkParagraphs( textarea ); | |
| 182 | } | |
| 183 | ||
| 184 | private void initHotKeys() { | |
| 185 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 186 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 187 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 188 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 189 | addEventListener( keyPressed( INSERT ), this::onInsertPressed ); | |
| 190 | } | |
| 191 | ||
| 192 | private void initUndoManager() { | |
| 193 | final var undoManager = getUndoManager(); | |
| 194 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 195 | ||
| 196 | undoManager.forgetHistory(); | |
| 197 | undoManager.mark(); | |
| 198 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 199 | } | |
| 200 | ||
| 201 | @Override | |
| 202 | public void moveTo( final int offset ) { | |
| 203 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 204 | mTextArea.moveTo( offset ); | |
| 205 | mTextArea.requestFollowCaret(); | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * Delegate the focus request to the text area itself. | |
| 210 | */ | |
| 211 | @Override | |
| 212 | public void requestFocus() { | |
| 213 | mTextArea.requestFocus(); | |
| 214 | } | |
| 215 | ||
| 216 | @Override | |
| 217 | public void setText( final String text ) { | |
| 218 | mTextArea.clear(); | |
| 219 | mTextArea.appendText( text ); | |
| 220 | mTextArea.getUndoManager().mark(); | |
| 221 | } | |
| 222 | ||
| 223 | @Override | |
| 224 | public String getText() { | |
| 225 | return mTextArea.getText(); | |
| 226 | } | |
| 227 | ||
| 228 | @Override | |
| 229 | public Charset getEncoding() { | |
| 230 | return mEncoding; | |
| 231 | } | |
| 232 | ||
| 233 | @Override | |
| 234 | public File getFile() { | |
| 235 | return mFile; | |
| 236 | } | |
| 237 | ||
| 238 | @Override | |
| 239 | public void rename( final File file ) { | |
| 240 | mFile = file; | |
| 241 | } | |
| 242 | ||
| 243 | @Override | |
| 244 | public void undo() { | |
| 245 | final var manager = getUndoManager(); | |
| 246 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 247 | } | |
| 248 | ||
| 249 | @Override | |
| 250 | public void redo() { | |
| 251 | final var manager = getUndoManager(); | |
| 252 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 257 | * message to the user. | |
| 258 | * | |
| 259 | * @param ready Answers whether the action can be executed. | |
| 260 | * @param action The action to execute. | |
| 261 | * @param key The informational message key having a value to display if | |
| 262 | * the {@link Supplier} is not ready. | |
| 263 | */ | |
| 264 | private void xxdo( | |
| 265 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 266 | if( ready.get() ) { | |
| 267 | action.run(); | |
| 268 | } | |
| 269 | else { | |
| 270 | clue( key ); | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | @Override | |
| 275 | public void cut() { | |
| 276 | final var selected = mTextArea.getSelectedText(); | |
| 277 | ||
| 278 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 279 | if( selected == null || selected.isEmpty() ) { | |
| 280 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 281 | mTextArea.fireEvent( keyDown( HOME, false ) ); | |
| 282 | mTextArea.fireEvent( keyDown( DOWN, true ) ); | |
| 283 | } | |
| 284 | ||
| 285 | mTextArea.cut(); | |
| 286 | } | |
| 287 | ||
| 288 | @Override | |
| 289 | public void copy() { | |
| 290 | mTextArea.copy(); | |
| 291 | } | |
| 292 | ||
| 293 | @Override | |
| 294 | public void paste() { | |
| 295 | mTextArea.paste(); | |
| 296 | } | |
| 297 | ||
| 298 | @Override | |
| 299 | public void selectAll() { | |
| 300 | mTextArea.selectAll(); | |
| 301 | } | |
| 302 | ||
| 303 | @Override | |
| 304 | public void bold() { | |
| 305 | enwrap( "**" ); | |
| 306 | } | |
| 307 | ||
| 308 | @Override | |
| 309 | public void italic() { | |
| 310 | enwrap( "*" ); | |
| 311 | } | |
| 312 | ||
| 313 | @Override | |
| 314 | public void superscript() { | |
| 315 | enwrap( "^" ); | |
| 316 | } | |
| 317 | ||
| 318 | @Override | |
| 319 | public void subscript() { | |
| 320 | enwrap( "~" ); | |
| 321 | } | |
| 322 | ||
| 323 | @Override | |
| 324 | public void strikethrough() { | |
| 325 | enwrap( "~~" ); | |
| 326 | } | |
| 327 | ||
| 328 | @Override | |
| 329 | public void blockquote() { | |
| 330 | block( "> " ); | |
| 331 | } | |
| 332 | ||
| 333 | @Override | |
| 334 | public void code() { | |
| 335 | enwrap( "`" ); | |
| 336 | } | |
| 337 | ||
| 338 | @Override | |
| 339 | public void fencedCodeBlock() { | |
| 340 | enwrap( "\n\n```\n", "\n```\n\n" ); | |
| 341 | } | |
| 342 | ||
| 343 | @Override | |
| 344 | public void heading( final int level ) { | |
| 345 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 346 | block( format( "%s ", hashes ) ); | |
| 347 | } | |
| 348 | ||
| 349 | @Override | |
| 350 | public void unorderedList() { | |
| 351 | block( "* " ); | |
| 352 | } | |
| 353 | ||
| 354 | @Override | |
| 355 | public void orderedList() { | |
| 356 | block( "1. " ); | |
| 357 | } | |
| 358 | ||
| 359 | @Override | |
| 360 | public void horizontalRule() { | |
| 361 | block( format( "---%n%n" ) ); | |
| 362 | } | |
| 363 | ||
| 364 | @Override | |
| 365 | public Node getNode() { | |
| 366 | return this; | |
| 367 | } | |
| 368 | ||
| 369 | @Override | |
| 370 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 371 | return mModified; | |
| 372 | } | |
| 373 | ||
| 374 | @Override | |
| 375 | public void clearModifiedProperty() { | |
| 376 | getUndoManager().mark(); | |
| 377 | } | |
| 378 | ||
| 379 | @Override | |
| 380 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 381 | return mScrollPane; | |
| 382 | } | |
| 383 | ||
| 384 | @Override | |
| 385 | public StyleClassedTextArea getTextArea() { | |
| 386 | return mTextArea; | |
| 387 | } | |
| 388 | ||
| 389 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 390 | ||
| 391 | @Override | |
| 392 | public void stylize( final IndexRange range, final String style ) { | |
| 393 | final var began = range.getStart(); | |
| 394 | final var ended = range.getEnd() + 1; | |
| 395 | ||
| 396 | assert 0 <= began && began <= ended; | |
| 397 | assert style != null; | |
| 398 | ||
| 399 | // TODO: Ensure spell check and find highlights can coexist. | |
| 400 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 401 | // System.out.println( "SPANS: " + spans ); | |
| 402 | ||
| 403 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 404 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 405 | // ) ); | |
| 406 | ||
| 407 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 408 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 409 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 410 | ||
| 411 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 412 | // System.out.println( "STYLES: " +s ); | |
| 413 | ||
| 414 | mStyles.put( style, range ); | |
| 415 | mTextArea.setStyleClass( began, ended, style ); | |
| 416 | ||
| 417 | // Ensure that whenever the user interacts with the text that the found | |
| 418 | // word will have its highlighting removed. The handler removes itself. | |
| 419 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 420 | final var handler = mTextArea.getOnKeyPressed(); | |
| 421 | mTextArea.setOnKeyPressed( ( event ) -> { | |
| 422 | mTextArea.setOnKeyPressed( handler ); | |
| 423 | unstylize( style ); | |
| 424 | } ); | |
| 425 | ||
| 426 | //mTextArea.setStyleSpans(began, ended, s); | |
| 427 | } | |
| 428 | ||
| 429 | private static StyleSpans<Collection<String>> merge( | |
| 430 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 431 | spans = spans.overlay( | |
| 432 | singleton( singletonList( style ), len ), | |
| 433 | ( bottomSpan, list ) -> { | |
| 434 | final List<String> l = | |
| 435 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 436 | l.addAll( bottomSpan ); | |
| 437 | l.addAll( list ); | |
| 438 | return l; | |
| 439 | } ); | |
| 440 | ||
| 441 | return spans; | |
| 442 | } | |
| 443 | ||
| 444 | @Override | |
| 445 | public void unstylize( final String style ) { | |
| 446 | final var indexes = mStyles.remove( style ); | |
| 447 | if( indexes != null ) { | |
| 448 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 449 | } | |
| 450 | } | |
| 451 | ||
| 452 | @Override | |
| 453 | public Caret getCaret() { | |
| 454 | return mCaret; | |
| 455 | } | |
| 456 | ||
| 457 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 458 | return Caret | |
| 459 | .builder() | |
| 460 | .with( Caret.Mutator::setEditor, editor ) | |
| 461 | .build(); | |
| 462 | } | |
| 463 | ||
| 464 | /** | |
| 465 | * This method adds listeners to editor events. | |
| 466 | * | |
| 467 | * @param <T> The event type. | |
| 468 | * @param <U> The consumer type for the given event type. | |
| 469 | * @param event The event of interest. | |
| 470 | * @param consumer The method to call when the event happens. | |
| 471 | */ | |
| 472 | public <T extends Event, U extends T> void addEventListener( | |
| 473 | final EventPattern<? super T, ? extends U> event, | |
| 474 | final Consumer<? super U> consumer ) { | |
| 475 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 476 | } | |
| 477 | ||
| 478 | private void onEnterPressed( final KeyEvent ignored ) { | |
| 479 | final var currentLine = getCaretParagraph(); | |
| 480 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 481 | ||
| 482 | // By default, insert a new line by itself. | |
| 483 | String newText = NEWLINE; | |
| 484 | ||
| 485 | // If the pattern was matched then determine what block type to continue. | |
| 486 | if( matcher.matches() ) { | |
| 487 | if( matcher.group( 2 ).isEmpty() ) { | |
| 488 | final var pos = mTextArea.getCaretPosition(); | |
| 489 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 490 | } | |
| 491 | else { | |
| 492 | // Indent the new line with the same whitespace characters and | |
| 493 | // list markers as current line. This ensures that the indentation | |
| 494 | // is propagated. | |
| 495 | newText = newText.concat( matcher.group( 1 ) ); | |
| 496 | } | |
| 497 | } | |
| 498 | ||
| 499 | mTextArea.replaceSelection( newText ); | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * TODO: 105 - Insert key toggle overwrite (typeover) mode | |
| 504 | * | |
| 505 | * @param ignored Unused. | |
| 506 | */ | |
| 507 | private void onInsertPressed( final KeyEvent ignored ) { | |
| 508 | } | |
| 509 | ||
| 510 | private void cut( final KeyEvent event ) { | |
| 511 | cut(); | |
| 512 | } | |
| 513 | ||
| 514 | private void tab( final KeyEvent event ) { | |
| 515 | final var range = mTextArea.selectionProperty().getValue(); | |
| 516 | final var sb = new StringBuilder( 1024 ); | |
| 517 | ||
| 518 | if( range.getLength() > 0 ) { | |
| 519 | final var selection = mTextArea.getSelectedText(); | |
| 520 | ||
| 521 | selection.lines().forEach( | |
| 522 | ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 523 | ); | |
| 524 | } | |
| 525 | else { | |
| 526 | sb.append( "\t" ); | |
| 527 | } | |
| 528 | ||
| 529 | mTextArea.replaceSelection( sb.toString() ); | |
| 530 | } | |
| 531 | ||
| 532 | private void untab( final KeyEvent event ) { | |
| 533 | final var range = mTextArea.selectionProperty().getValue(); | |
| 534 | ||
| 535 | if( range.getLength() > 0 ) { | |
| 536 | final var selection = mTextArea.getSelectedText(); | |
| 537 | final var sb = new StringBuilder( selection.length() ); | |
| 538 | ||
| 539 | selection.lines().forEach( | |
| 540 | ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 541 | .append( NEWLINE ) | |
| 542 | ); | |
| 543 | ||
| 544 | mTextArea.replaceSelection( sb.toString() ); | |
| 545 | } | |
| 546 | else { | |
| 547 | final var p = getCaretParagraph(); | |
| 548 | ||
| 549 | if( p.startsWith( "\t" ) ) { | |
| 550 | mTextArea.selectParagraph(); | |
| 551 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 552 | } | |
| 553 | } | |
| 554 | } | |
| 555 | ||
| 556 | /** | |
| 557 | * Observers may listen for changes to the property returned from this method | |
| 558 | * to receive notifications when either the text or caret have changed. This | |
| 559 | * should not be used to track whether the text has been modified. | |
| 560 | */ | |
| 561 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 562 | mDirty.addListener( listener ); | |
| 563 | } | |
| 564 | ||
| 565 | /** | |
| 566 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 567 | * | |
| 568 | * @param token The beginning and ending token for enclosing the text. | |
| 569 | */ | |
| 570 | private void enwrap( final String token ) { | |
| 571 | enwrap( token, token ); | |
| 572 | } | |
| 573 | ||
| 574 | /** | |
| 575 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 576 | * | |
| 577 | * @param began The beginning token for enclosing the text. | |
| 578 | * @param ended The ending token for enclosing the text. | |
| 579 | */ | |
| 580 | private void enwrap( final String began, String ended ) { | |
| 581 | // Ensure selected text takes precedence over the word at caret position. | |
| 582 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 583 | final var range = selected.getLength() == 0 | |
| 584 | ? getCaretWord() | |
| 585 | : selected; | |
| 586 | String text = mTextArea.getText( range ); | |
| 587 | ||
| 588 | int length = range.getLength(); | |
| 589 | text = stripStart( text, null ); | |
| 590 | final int beganIndex = range.getStart() + (length - text.length()); | |
| 591 | ||
| 592 | length = text.length(); | |
| 593 | text = stripEnd( text, null ); | |
| 594 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 595 | ||
| 596 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 597 | } | |
| 598 | ||
| 599 | /** | |
| 600 | * Inserts the given block-level markup at the current caret position | |
| 601 | * within the document. This will prepend two blank lines to ensure that | |
| 602 | * the block element begins at the start of a new line. | |
| 603 | * | |
| 604 | * @param markup The text to insert at the caret. | |
| 605 | */ | |
| 606 | private void block( final String markup ) { | |
| 607 | final int pos = mTextArea.getCaretPosition(); | |
| 608 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 609 | } | |
| 610 | ||
| 611 | /** | |
| 612 | * Returns the caret position within the current paragraph. | |
| 613 | * | |
| 614 | * @return A value from 0 to the length of the current paragraph. | |
| 615 | */ | |
| 616 | private int getCaretColumn() { | |
| 617 | return mTextArea.getCaretColumn(); | |
| 618 | } | |
| 619 | ||
| 620 | @Override | |
| 621 | public IndexRange getCaretWord() { | |
| 622 | final var paragraph = getCaretParagraph(); | |
| 623 | final var length = paragraph.length(); | |
| 624 | final var column = getCaretColumn(); | |
| 625 | ||
| 626 | var began = column; | |
| 627 | var ended = column; | |
| 628 | ||
| 629 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 630 | began--; | |
| 631 | } | |
| 632 | ||
| 633 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 634 | ended++; | |
| 635 | } | |
| 636 | ||
| 637 | final var iterator = BreakIterator.getWordInstance(); | |
| 638 | iterator.setText( paragraph ); | |
| 639 | ||
| 640 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 641 | began++; | |
| 642 | } | |
| 643 | ||
| 644 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 645 | ended--; | |
| 646 | } | |
| 647 | ||
| 648 | final var offset = getCaretDocumentOffset( column ); | |
| 649 | ||
| 650 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 651 | } | |
| 652 | ||
| 653 | private int getCaretDocumentOffset( final int column ) { | |
| 654 | return mTextArea.getCaretPosition() - column; | |
| 655 | } | |
| 656 | ||
| 657 | /** | |
| 658 | * Returns the index of the paragraph where the caret resides. | |
| 659 | * | |
| 660 | * @return A number greater than or equal to 0. | |
| 661 | */ | |
| 662 | private int getCurrentParagraph() { | |
| 663 | return mTextArea.getCurrentParagraph(); | |
| 664 | } | |
| 665 | ||
| 666 | /** | |
| 667 | * Returns the text for the paragraph that contains the caret. | |
| 668 | * | |
| 669 | * @return A non-null string, possibly empty. | |
| 670 | */ | |
| 671 | private String getCaretParagraph() { | |
| 672 | return getText( getCurrentParagraph() ); | |
| 673 | } | |
| 674 | ||
| 675 | @Override | |
| 676 | public String getText( final int paragraph ) { | |
| 677 | return mTextArea.getText( paragraph ); | |
| 678 | } | |
| 679 | ||
| 680 | @Override | |
| 681 | public String getText( final IndexRange indexes ) | |
| 682 | throws IndexOutOfBoundsException { | |
| 683 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 684 | } | |
| 685 | ||
| 686 | @Override | |
| 687 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 688 | mTextArea.replaceText( indexes, s ); | |
| 689 | } | |
| 690 | ||
| 691 | private UndoManager<?> getUndoManager() { | |
| 692 | return mTextArea.getUndoManager(); | |
| 693 | } | |
| 694 | ||
| 695 | /** | |
| 696 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 697 | * | |
| 698 | * @return A non-null string to inject into the HTML document head. | |
| 699 | */ | |
| 700 | private static String getStylesheetPath( final Locale locale ) { | |
| 701 | return get( | |
| 702 | sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | |
| 703 | locale.getLanguage(), | |
| 704 | locale.getScript(), | |
| 705 | locale.getCountry() | |
| 706 | ); | |
| 707 | } | |
| 708 | ||
| 709 | private Locale getLocale() { | |
| 710 | return localeProperty().toLocale(); | |
| 711 | } | |
| 712 | ||
| 713 | private LocaleProperty localeProperty() { | |
| 714 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 42 | import static javafx.application.Platform.runLater; | |
| 43 | import static javafx.scene.control.ScrollPane.ScrollBarPolicy.ALWAYS; | |
| 44 | import static javafx.scene.input.KeyCode.*; | |
| 45 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 46 | import static javafx.scene.input.KeyCombination.SHIFT_DOWN; | |
| 47 | import static org.apache.commons.lang3.StringUtils.stripEnd; | |
| 48 | import static org.apache.commons.lang3.StringUtils.stripStart; | |
| 49 | import static org.fxmisc.richtext.model.StyleSpans.singleton; | |
| 50 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 51 | import static org.fxmisc.wellbehaved.event.InputMap.consume; | |
| 52 | ||
| 53 | /** | |
| 54 | * Responsible for editing Markdown documents. | |
| 55 | */ | |
| 56 | public final class MarkdownEditor extends BorderPane implements TextEditor { | |
| 57 | /** | |
| 58 | * Regular expression that matches the type of markup block. This is used | |
| 59 | * when Enter is pressed to continue the block environment. | |
| 60 | */ | |
| 61 | private static final Pattern PATTERN_AUTO_INDENT = Pattern.compile( | |
| 62 | "(\\s*[*+-]\\s+|\\s*[0-9]+\\.\\s+|\\s+)(.*)" ); | |
| 63 | ||
| 64 | /** | |
| 65 | * The text editor. | |
| 66 | */ | |
| 67 | private final StyleClassedTextArea mTextArea = | |
| 68 | new StyleClassedTextArea( false ); | |
| 69 | ||
| 70 | /** | |
| 71 | * Wraps the text editor in scrollbars. | |
| 72 | */ | |
| 73 | private final VirtualizedScrollPane<StyleClassedTextArea> mScrollPane = | |
| 74 | new VirtualizedScrollPane<>( mTextArea ); | |
| 75 | ||
| 76 | private final Workspace mWorkspace; | |
| 77 | ||
| 78 | /** | |
| 79 | * Tracks where the caret is located in this document. This offers observable | |
| 80 | * properties for caret position changes. | |
| 81 | */ | |
| 82 | private final Caret mCaret = createCaret( mTextArea ); | |
| 83 | ||
| 84 | /** | |
| 85 | * File being edited by this editor instance. | |
| 86 | */ | |
| 87 | private File mFile; | |
| 88 | ||
| 89 | /** | |
| 90 | * Set to {@code true} upon text or caret position changes. Value is {@code | |
| 91 | * false} by default. | |
| 92 | */ | |
| 93 | private final BooleanProperty mDirty = new SimpleBooleanProperty(); | |
| 94 | ||
| 95 | /** | |
| 96 | * Opened file's character encoding, or {@link Constants#DEFAULT_CHARSET} if | |
| 97 | * either no encoding could be determined or this is a new (empty) file. | |
| 98 | */ | |
| 99 | private final Charset mEncoding; | |
| 100 | ||
| 101 | /** | |
| 102 | * Tracks whether the in-memory definitions have changed with respect to the | |
| 103 | * persisted definitions. | |
| 104 | */ | |
| 105 | private final BooleanProperty mModified = new SimpleBooleanProperty(); | |
| 106 | ||
| 107 | public MarkdownEditor( final Workspace workspace ) { | |
| 108 | this( DOCUMENT_DEFAULT, workspace ); | |
| 109 | } | |
| 110 | ||
| 111 | public MarkdownEditor( final File file, final Workspace workspace ) { | |
| 112 | mEncoding = open( mFile = file ); | |
| 113 | mWorkspace = workspace; | |
| 114 | ||
| 115 | initTextArea( mTextArea ); | |
| 116 | initStyle( mTextArea ); | |
| 117 | initScrollPane( mScrollPane ); | |
| 118 | initSpellchecker( mTextArea ); | |
| 119 | initHotKeys(); | |
| 120 | initUndoManager(); | |
| 121 | } | |
| 122 | ||
| 123 | private void initTextArea( final StyleClassedTextArea textArea ) { | |
| 124 | textArea.setWrapText( true ); | |
| 125 | textArea.requestFollowCaret(); | |
| 126 | textArea.moveTo( 0 ); | |
| 127 | ||
| 128 | textArea.textProperty().addListener( ( c, o, n ) -> { | |
| 129 | // Fire, regardless of whether the caret position has changed. | |
| 130 | mDirty.set( false ); | |
| 131 | ||
| 132 | // Prevent a caret position change from raising the dirty bits. | |
| 133 | mDirty.set( true ); | |
| 134 | } ); | |
| 135 | ||
| 136 | textArea.caretPositionProperty().addListener( ( c, o, n ) -> { | |
| 137 | // Fire when the caret position has changed and the text has not. | |
| 138 | mDirty.set( true ); | |
| 139 | mDirty.set( false ); | |
| 140 | } ); | |
| 141 | ||
| 142 | textArea.focusedProperty().addListener( ( c, o, n ) -> { | |
| 143 | if( n != null && n ) { | |
| 144 | fireTextEditorFocus( this ); | |
| 145 | } | |
| 146 | } ); | |
| 147 | } | |
| 148 | ||
| 149 | private void initStyle( final StyleClassedTextArea textArea ) { | |
| 150 | textArea.getStyleClass().add( "markdown" ); | |
| 151 | ||
| 152 | final var stylesheets = textArea.getStylesheets(); | |
| 153 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 154 | ||
| 155 | localeProperty().addListener( ( c, o, n ) -> { | |
| 156 | if( n != null ) { | |
| 157 | stylesheets.clear(); | |
| 158 | stylesheets.add( getStylesheetPath( getLocale() ) ); | |
| 159 | } | |
| 160 | } ); | |
| 161 | ||
| 162 | fontNameProperty().addListener( | |
| 163 | ( c, o, n ) -> | |
| 164 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 165 | ); | |
| 166 | ||
| 167 | fontSizeProperty().addListener( | |
| 168 | ( c, o, n ) -> | |
| 169 | setFont( mTextArea, getFontName(), getFontSize() ) | |
| 170 | ); | |
| 171 | ||
| 172 | setFont( mTextArea, getFontName(), getFontSize() ); | |
| 173 | } | |
| 174 | ||
| 175 | private void initScrollPane( | |
| 176 | final VirtualizedScrollPane<StyleClassedTextArea> scrollpane ) { | |
| 177 | scrollpane.setVbarPolicy( ALWAYS ); | |
| 178 | setCenter( scrollpane ); | |
| 179 | } | |
| 180 | ||
| 181 | private void initSpellchecker( final StyleClassedTextArea textarea ) { | |
| 182 | final var speller = new TextEditorSpeller(); | |
| 183 | speller.checkDocument( textarea ); | |
| 184 | speller.checkParagraphs( textarea ); | |
| 185 | } | |
| 186 | ||
| 187 | private void initHotKeys() { | |
| 188 | addEventListener( keyPressed( ENTER ), this::onEnterPressed ); | |
| 189 | addEventListener( keyPressed( X, CONTROL_DOWN ), this::cut ); | |
| 190 | addEventListener( keyPressed( TAB ), this::tab ); | |
| 191 | addEventListener( keyPressed( TAB, SHIFT_DOWN ), this::untab ); | |
| 192 | addEventListener( keyPressed( INSERT ), this::onInsertPressed ); | |
| 193 | } | |
| 194 | ||
| 195 | private void initUndoManager() { | |
| 196 | final var undoManager = getUndoManager(); | |
| 197 | final var markedPosition = undoManager.atMarkedPositionProperty(); | |
| 198 | ||
| 199 | undoManager.forgetHistory(); | |
| 200 | undoManager.mark(); | |
| 201 | mModified.bind( Bindings.not( markedPosition ) ); | |
| 202 | } | |
| 203 | ||
| 204 | @Override | |
| 205 | public void moveTo( final int offset ) { | |
| 206 | assert 0 <= offset && offset <= mTextArea.getLength(); | |
| 207 | mTextArea.moveTo( offset ); | |
| 208 | mTextArea.requestFollowCaret(); | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Delegate the focus request to the text area itself. | |
| 213 | */ | |
| 214 | @Override | |
| 215 | public void requestFocus() { | |
| 216 | mTextArea.requestFocus(); | |
| 217 | } | |
| 218 | ||
| 219 | @Override | |
| 220 | public void setText( final String text ) { | |
| 221 | mTextArea.clear(); | |
| 222 | mTextArea.appendText( text ); | |
| 223 | mTextArea.getUndoManager().mark(); | |
| 224 | } | |
| 225 | ||
| 226 | @Override | |
| 227 | public String getText() { | |
| 228 | return mTextArea.getText(); | |
| 229 | } | |
| 230 | ||
| 231 | @Override | |
| 232 | public Charset getEncoding() { | |
| 233 | return mEncoding; | |
| 234 | } | |
| 235 | ||
| 236 | @Override | |
| 237 | public File getFile() { | |
| 238 | return mFile; | |
| 239 | } | |
| 240 | ||
| 241 | @Override | |
| 242 | public void rename( final File file ) { | |
| 243 | mFile = file; | |
| 244 | } | |
| 245 | ||
| 246 | @Override | |
| 247 | public void undo() { | |
| 248 | final var manager = getUndoManager(); | |
| 249 | xxdo( manager::isUndoAvailable, manager::undo, "Main.status.error.undo" ); | |
| 250 | } | |
| 251 | ||
| 252 | @Override | |
| 253 | public void redo() { | |
| 254 | final var manager = getUndoManager(); | |
| 255 | xxdo( manager::isRedoAvailable, manager::redo, "Main.status.error.redo" ); | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Performs an undo or redo action, if possible, otherwise displays an error | |
| 260 | * message to the user. | |
| 261 | * | |
| 262 | * @param ready Answers whether the action can be executed. | |
| 263 | * @param action The action to execute. | |
| 264 | * @param key The informational message key having a value to display if | |
| 265 | * the {@link Supplier} is not ready. | |
| 266 | */ | |
| 267 | private void xxdo( | |
| 268 | final Supplier<Boolean> ready, final Runnable action, final String key ) { | |
| 269 | if( ready.get() ) { | |
| 270 | action.run(); | |
| 271 | } | |
| 272 | else { | |
| 273 | clue( key ); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | @Override | |
| 278 | public void cut() { | |
| 279 | final var selected = mTextArea.getSelectedText(); | |
| 280 | ||
| 281 | // Emulate selecting the current line by firing Home then Shift+Down Arrow. | |
| 282 | if( selected == null || selected.isEmpty() ) { | |
| 283 | // Note: mTextArea.selectLine() does not select empty lines. | |
| 284 | mTextArea.fireEvent( keyDown( HOME, false ) ); | |
| 285 | mTextArea.fireEvent( keyDown( DOWN, true ) ); | |
| 286 | } | |
| 287 | ||
| 288 | mTextArea.cut(); | |
| 289 | } | |
| 290 | ||
| 291 | @Override | |
| 292 | public void copy() { | |
| 293 | mTextArea.copy(); | |
| 294 | } | |
| 295 | ||
| 296 | @Override | |
| 297 | public void paste() { | |
| 298 | mTextArea.paste(); | |
| 299 | } | |
| 300 | ||
| 301 | @Override | |
| 302 | public void selectAll() { | |
| 303 | mTextArea.selectAll(); | |
| 304 | } | |
| 305 | ||
| 306 | @Override | |
| 307 | public void bold() { | |
| 308 | enwrap( "**" ); | |
| 309 | } | |
| 310 | ||
| 311 | @Override | |
| 312 | public void italic() { | |
| 313 | enwrap( "*" ); | |
| 314 | } | |
| 315 | ||
| 316 | @Override | |
| 317 | public void superscript() { | |
| 318 | enwrap( "^" ); | |
| 319 | } | |
| 320 | ||
| 321 | @Override | |
| 322 | public void subscript() { | |
| 323 | enwrap( "~" ); | |
| 324 | } | |
| 325 | ||
| 326 | @Override | |
| 327 | public void strikethrough() { | |
| 328 | enwrap( "~~" ); | |
| 329 | } | |
| 330 | ||
| 331 | @Override | |
| 332 | public void blockquote() { | |
| 333 | block( "> " ); | |
| 334 | } | |
| 335 | ||
| 336 | @Override | |
| 337 | public void code() { | |
| 338 | enwrap( "`" ); | |
| 339 | } | |
| 340 | ||
| 341 | @Override | |
| 342 | public void fencedCodeBlock() { | |
| 343 | enwrap( "\n\n```\n", "\n```\n\n" ); | |
| 344 | } | |
| 345 | ||
| 346 | @Override | |
| 347 | public void heading( final int level ) { | |
| 348 | final var hashes = new String( new char[ level ] ).replace( "\0", "#" ); | |
| 349 | block( format( "%s ", hashes ) ); | |
| 350 | } | |
| 351 | ||
| 352 | @Override | |
| 353 | public void unorderedList() { | |
| 354 | block( "* " ); | |
| 355 | } | |
| 356 | ||
| 357 | @Override | |
| 358 | public void orderedList() { | |
| 359 | block( "1. " ); | |
| 360 | } | |
| 361 | ||
| 362 | @Override | |
| 363 | public void horizontalRule() { | |
| 364 | block( format( "---%n%n" ) ); | |
| 365 | } | |
| 366 | ||
| 367 | @Override | |
| 368 | public Node getNode() { | |
| 369 | return this; | |
| 370 | } | |
| 371 | ||
| 372 | @Override | |
| 373 | public ReadOnlyBooleanProperty modifiedProperty() { | |
| 374 | return mModified; | |
| 375 | } | |
| 376 | ||
| 377 | @Override | |
| 378 | public void clearModifiedProperty() { | |
| 379 | getUndoManager().mark(); | |
| 380 | } | |
| 381 | ||
| 382 | @Override | |
| 383 | public VirtualizedScrollPane<StyleClassedTextArea> getScrollPane() { | |
| 384 | return mScrollPane; | |
| 385 | } | |
| 386 | ||
| 387 | @Override | |
| 388 | public StyleClassedTextArea getTextArea() { | |
| 389 | return mTextArea; | |
| 390 | } | |
| 391 | ||
| 392 | private final Map<String, IndexRange> mStyles = new HashMap<>(); | |
| 393 | ||
| 394 | @Override | |
| 395 | public void stylize( final IndexRange range, final String style ) { | |
| 396 | final var began = range.getStart(); | |
| 397 | final var ended = range.getEnd() + 1; | |
| 398 | ||
| 399 | assert 0 <= began && began <= ended; | |
| 400 | assert style != null; | |
| 401 | ||
| 402 | // TODO: Ensure spell check and find highlights can coexist. | |
| 403 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 404 | // System.out.println( "SPANS: " + spans ); | |
| 405 | ||
| 406 | // final var spans = mTextArea.getStyleSpans( range ); | |
| 407 | // mTextArea.setStyleSpans( began, merge( spans, range.getLength(), style | |
| 408 | // ) ); | |
| 409 | ||
| 410 | // final var builder = new StyleSpansBuilder<Collection<String>>(); | |
| 411 | // builder.add( singleton( style ), range.getLength() + 1 ); | |
| 412 | // mTextArea.setStyleSpans( began, builder.create() ); | |
| 413 | ||
| 414 | // final var s = mTextArea.getStyleSpans( began, ended ); | |
| 415 | // System.out.println( "STYLES: " +s ); | |
| 416 | ||
| 417 | mStyles.put( style, range ); | |
| 418 | mTextArea.setStyleClass( began, ended, style ); | |
| 419 | ||
| 420 | // Ensure that whenever the user interacts with the text that the found | |
| 421 | // word will have its highlighting removed. The handler removes itself. | |
| 422 | // This won't remove the highlighting if the caret position moves by mouse. | |
| 423 | final var handler = mTextArea.getOnKeyPressed(); | |
| 424 | mTextArea.setOnKeyPressed( ( event ) -> { | |
| 425 | mTextArea.setOnKeyPressed( handler ); | |
| 426 | unstylize( style ); | |
| 427 | } ); | |
| 428 | ||
| 429 | //mTextArea.setStyleSpans(began, ended, s); | |
| 430 | } | |
| 431 | ||
| 432 | private static StyleSpans<Collection<String>> merge( | |
| 433 | StyleSpans<Collection<String>> spans, int len, String style ) { | |
| 434 | spans = spans.overlay( | |
| 435 | singleton( singletonList( style ), len ), | |
| 436 | ( bottomSpan, list ) -> { | |
| 437 | final List<String> l = | |
| 438 | new ArrayList<>( bottomSpan.size() + list.size() ); | |
| 439 | l.addAll( bottomSpan ); | |
| 440 | l.addAll( list ); | |
| 441 | return l; | |
| 442 | } ); | |
| 443 | ||
| 444 | return spans; | |
| 445 | } | |
| 446 | ||
| 447 | @Override | |
| 448 | public void unstylize( final String style ) { | |
| 449 | final var indexes = mStyles.remove( style ); | |
| 450 | if( indexes != null ) { | |
| 451 | mTextArea.clearStyle( indexes.getStart(), indexes.getEnd() + 1 ); | |
| 452 | } | |
| 453 | } | |
| 454 | ||
| 455 | @Override | |
| 456 | public Caret getCaret() { | |
| 457 | return mCaret; | |
| 458 | } | |
| 459 | ||
| 460 | private Caret createCaret( final StyleClassedTextArea editor ) { | |
| 461 | return Caret | |
| 462 | .builder() | |
| 463 | .with( Caret.Mutator::setEditor, editor ) | |
| 464 | .build(); | |
| 465 | } | |
| 466 | ||
| 467 | /** | |
| 468 | * This method adds listeners to editor events. | |
| 469 | * | |
| 470 | * @param <T> The event type. | |
| 471 | * @param <U> The consumer type for the given event type. | |
| 472 | * @param event The event of interest. | |
| 473 | * @param consumer The method to call when the event happens. | |
| 474 | */ | |
| 475 | public <T extends Event, U extends T> void addEventListener( | |
| 476 | final EventPattern<? super T, ? extends U> event, | |
| 477 | final Consumer<? super U> consumer ) { | |
| 478 | Nodes.addInputMap( mTextArea, consume( event, consumer ) ); | |
| 479 | } | |
| 480 | ||
| 481 | private void onEnterPressed( final KeyEvent ignored ) { | |
| 482 | final var currentLine = getCaretParagraph(); | |
| 483 | final var matcher = PATTERN_AUTO_INDENT.matcher( currentLine ); | |
| 484 | ||
| 485 | // By default, insert a new line by itself. | |
| 486 | String newText = NEWLINE; | |
| 487 | ||
| 488 | // If the pattern was matched then determine what block type to continue. | |
| 489 | if( matcher.matches() ) { | |
| 490 | if( matcher.group( 2 ).isEmpty() ) { | |
| 491 | final var pos = mTextArea.getCaretPosition(); | |
| 492 | mTextArea.selectRange( pos - currentLine.length(), pos ); | |
| 493 | } | |
| 494 | else { | |
| 495 | // Indent the new line with the same whitespace characters and | |
| 496 | // list markers as current line. This ensures that the indentation | |
| 497 | // is propagated. | |
| 498 | newText = newText.concat( matcher.group( 1 ) ); | |
| 499 | } | |
| 500 | } | |
| 501 | ||
| 502 | mTextArea.replaceSelection( newText ); | |
| 503 | } | |
| 504 | ||
| 505 | /** | |
| 506 | * TODO: 105 - Insert key toggle overwrite (typeover) mode | |
| 507 | * | |
| 508 | * @param ignored Unused. | |
| 509 | */ | |
| 510 | private void onInsertPressed( final KeyEvent ignored ) { | |
| 511 | } | |
| 512 | ||
| 513 | private void cut( final KeyEvent event ) { | |
| 514 | cut(); | |
| 515 | } | |
| 516 | ||
| 517 | private void tab( final KeyEvent event ) { | |
| 518 | final var range = mTextArea.selectionProperty().getValue(); | |
| 519 | final var sb = new StringBuilder( 1024 ); | |
| 520 | ||
| 521 | if( range.getLength() > 0 ) { | |
| 522 | final var selection = mTextArea.getSelectedText(); | |
| 523 | ||
| 524 | selection.lines().forEach( | |
| 525 | ( l ) -> sb.append( "\t" ).append( l ).append( NEWLINE ) | |
| 526 | ); | |
| 527 | } | |
| 528 | else { | |
| 529 | sb.append( "\t" ); | |
| 530 | } | |
| 531 | ||
| 532 | mTextArea.replaceSelection( sb.toString() ); | |
| 533 | } | |
| 534 | ||
| 535 | private void untab( final KeyEvent event ) { | |
| 536 | final var range = mTextArea.selectionProperty().getValue(); | |
| 537 | ||
| 538 | if( range.getLength() > 0 ) { | |
| 539 | final var selection = mTextArea.getSelectedText(); | |
| 540 | final var sb = new StringBuilder( selection.length() ); | |
| 541 | ||
| 542 | selection.lines().forEach( | |
| 543 | ( l ) -> sb.append( l.startsWith( "\t" ) ? l.substring( 1 ) : l ) | |
| 544 | .append( NEWLINE ) | |
| 545 | ); | |
| 546 | ||
| 547 | mTextArea.replaceSelection( sb.toString() ); | |
| 548 | } | |
| 549 | else { | |
| 550 | final var p = getCaretParagraph(); | |
| 551 | ||
| 552 | if( p.startsWith( "\t" ) ) { | |
| 553 | mTextArea.selectParagraph(); | |
| 554 | mTextArea.replaceSelection( p.substring( 1 ) ); | |
| 555 | } | |
| 556 | } | |
| 557 | } | |
| 558 | ||
| 559 | /** | |
| 560 | * Observers may listen for changes to the property returned from this method | |
| 561 | * to receive notifications when either the text or caret have changed. This | |
| 562 | * should not be used to track whether the text has been modified. | |
| 563 | */ | |
| 564 | public void addDirtyListener( ChangeListener<Boolean> listener ) { | |
| 565 | mDirty.addListener( listener ); | |
| 566 | } | |
| 567 | ||
| 568 | /** | |
| 569 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 570 | * | |
| 571 | * @param token The beginning and ending token for enclosing the text. | |
| 572 | */ | |
| 573 | private void enwrap( final String token ) { | |
| 574 | enwrap( token, token ); | |
| 575 | } | |
| 576 | ||
| 577 | /** | |
| 578 | * Surrounds the selected text or word under the caret in Markdown markup. | |
| 579 | * | |
| 580 | * @param began The beginning token for enclosing the text. | |
| 581 | * @param ended The ending token for enclosing the text. | |
| 582 | */ | |
| 583 | private void enwrap( final String began, String ended ) { | |
| 584 | // Ensure selected text takes precedence over the word at caret position. | |
| 585 | final var selected = mTextArea.selectionProperty().getValue(); | |
| 586 | final var range = selected.getLength() == 0 | |
| 587 | ? getCaretWord() | |
| 588 | : selected; | |
| 589 | String text = mTextArea.getText( range ); | |
| 590 | ||
| 591 | int length = range.getLength(); | |
| 592 | text = stripStart( text, null ); | |
| 593 | final int beganIndex = range.getStart() + (length - text.length()); | |
| 594 | ||
| 595 | length = text.length(); | |
| 596 | text = stripEnd( text, null ); | |
| 597 | final int endedIndex = range.getEnd() - (length - text.length()); | |
| 598 | ||
| 599 | mTextArea.replaceText( beganIndex, endedIndex, began + text + ended ); | |
| 600 | } | |
| 601 | ||
| 602 | /** | |
| 603 | * Inserts the given block-level markup at the current caret position | |
| 604 | * within the document. This will prepend two blank lines to ensure that | |
| 605 | * the block element begins at the start of a new line. | |
| 606 | * | |
| 607 | * @param markup The text to insert at the caret. | |
| 608 | */ | |
| 609 | private void block( final String markup ) { | |
| 610 | final int pos = mTextArea.getCaretPosition(); | |
| 611 | mTextArea.insertText( pos, format( "%n%n%s", markup ) ); | |
| 612 | } | |
| 613 | ||
| 614 | /** | |
| 615 | * Returns the caret position within the current paragraph. | |
| 616 | * | |
| 617 | * @return A value from 0 to the length of the current paragraph. | |
| 618 | */ | |
| 619 | private int getCaretColumn() { | |
| 620 | return mTextArea.getCaretColumn(); | |
| 621 | } | |
| 622 | ||
| 623 | @Override | |
| 624 | public IndexRange getCaretWord() { | |
| 625 | final var paragraph = getCaretParagraph(); | |
| 626 | final var length = paragraph.length(); | |
| 627 | final var column = getCaretColumn(); | |
| 628 | ||
| 629 | var began = column; | |
| 630 | var ended = column; | |
| 631 | ||
| 632 | while( began > 0 && !isWhitespace( paragraph.charAt( began - 1 ) ) ) { | |
| 633 | began--; | |
| 634 | } | |
| 635 | ||
| 636 | while( ended < length && !isWhitespace( paragraph.charAt( ended ) ) ) { | |
| 637 | ended++; | |
| 638 | } | |
| 639 | ||
| 640 | final var iterator = BreakIterator.getWordInstance(); | |
| 641 | iterator.setText( paragraph ); | |
| 642 | ||
| 643 | while( began < length && iterator.isBoundary( began + 1 ) ) { | |
| 644 | began++; | |
| 645 | } | |
| 646 | ||
| 647 | while( ended > 0 && iterator.isBoundary( ended - 1 ) ) { | |
| 648 | ended--; | |
| 649 | } | |
| 650 | ||
| 651 | final var offset = getCaretDocumentOffset( column ); | |
| 652 | ||
| 653 | return IndexRange.normalize( began + offset, ended + offset ); | |
| 654 | } | |
| 655 | ||
| 656 | private int getCaretDocumentOffset( final int column ) { | |
| 657 | return mTextArea.getCaretPosition() - column; | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Returns the index of the paragraph where the caret resides. | |
| 662 | * | |
| 663 | * @return A number greater than or equal to 0. | |
| 664 | */ | |
| 665 | private int getCurrentParagraph() { | |
| 666 | return mTextArea.getCurrentParagraph(); | |
| 667 | } | |
| 668 | ||
| 669 | /** | |
| 670 | * Returns the text for the paragraph that contains the caret. | |
| 671 | * | |
| 672 | * @return A non-null string, possibly empty. | |
| 673 | */ | |
| 674 | private String getCaretParagraph() { | |
| 675 | return getText( getCurrentParagraph() ); | |
| 676 | } | |
| 677 | ||
| 678 | @Override | |
| 679 | public String getText( final int paragraph ) { | |
| 680 | return mTextArea.getText( paragraph ); | |
| 681 | } | |
| 682 | ||
| 683 | @Override | |
| 684 | public String getText( final IndexRange indexes ) | |
| 685 | throws IndexOutOfBoundsException { | |
| 686 | return mTextArea.getText( indexes.getStart(), indexes.getEnd() ); | |
| 687 | } | |
| 688 | ||
| 689 | @Override | |
| 690 | public void replaceText( final IndexRange indexes, final String s ) { | |
| 691 | mTextArea.replaceText( indexes, s ); | |
| 692 | } | |
| 693 | ||
| 694 | private UndoManager<?> getUndoManager() { | |
| 695 | return mTextArea.getUndoManager(); | |
| 696 | } | |
| 697 | ||
| 698 | /** | |
| 699 | * Returns the path to a {@link Locale}-specific stylesheet. | |
| 700 | * | |
| 701 | * @return A non-null string to inject into the HTML document head. | |
| 702 | */ | |
| 703 | private static String getStylesheetPath( final Locale locale ) { | |
| 704 | return get( | |
| 705 | sSettings.getSetting( STYLESHEET_MARKDOWN_LOCALE, "" ), | |
| 706 | locale.getLanguage(), | |
| 707 | locale.getScript(), | |
| 708 | locale.getCountry() | |
| 709 | ); | |
| 710 | } | |
| 711 | ||
| 712 | private Locale getLocale() { | |
| 713 | return localeProperty().toLocale(); | |
| 714 | } | |
| 715 | ||
| 716 | private LocaleProperty localeProperty() { | |
| 717 | return mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 718 | } | |
| 719 | ||
| 720 | /** | |
| 721 | * Sets the font family name and font size at the same time. When the | |
| 722 | * workspace is loaded, the default font values are changed, which results | |
| 723 | * in this method being called. | |
| 724 | * | |
| 725 | * @param area Change the font settings for this text area. | |
| 726 | * @param name New font family name to apply. | |
| 727 | * @param points New font size to apply (in points, not pixels). | |
| 728 | */ | |
| 729 | private void setFont( | |
| 730 | final StyleClassedTextArea area, final String name, final double points ) { | |
| 731 | runLater( () -> area.setStyle( | |
| 732 | format( | |
| 733 | "-fx-font-family:'%s';-fx-font-size:%spx;", name, toPixels( points ) | |
| 734 | ) | |
| 735 | ) ); | |
| 715 | 736 | } |
| 716 | 737 |
| 2 | 2 | package com.keenwrite.events; |
| 3 | 3 | |
| 4 | import com.keenwrite.outline.DocumentOutline; | |
| 4 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 5 | 5 | |
| 6 | 6 | /** |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | import org.jsoup.nodes.Document; | |
| 5 | ||
| 6 | import static com.keenwrite.util.MurmurHash.hash32; | |
| 7 | import static java.lang.System.currentTimeMillis; | |
| 8 | ||
| 9 | /** | |
| 10 | * Collates information about an HTML document that has changed. | |
| 11 | */ | |
| 12 | public class DocumentChangedEvent implements AppEvent { | |
| 13 | private static final int SEED = (int) currentTimeMillis(); | |
| 14 | ||
| 15 | private final String mText; | |
| 16 | ||
| 17 | /** | |
| 18 | * Hash the document so subscribers are only informed upon changes. | |
| 19 | */ | |
| 20 | private static int sHash; | |
| 21 | ||
| 22 | /** | |
| 23 | * Creates an event with the new plain text document, having all variables | |
| 24 | * substituted and all markup removed. | |
| 25 | * | |
| 26 | * @param text The document text that has changed since the last time this | |
| 27 | * type of event was fired. | |
| 28 | */ | |
| 29 | private DocumentChangedEvent( final String text ) { | |
| 30 | mText = text; | |
| 31 | } | |
| 32 | ||
| 33 | /** | |
| 34 | * When the given document may have changed. This will only fire a change | |
| 35 | * event if the given document has changed from the last time this | |
| 36 | * event was fired. The document is first converted to plain text before | |
| 37 | * the comparison is made. | |
| 38 | * | |
| 39 | * @param html The document that may have changed. | |
| 40 | */ | |
| 41 | public static void fireDocumentChangedEvent( final Document html ) { | |
| 42 | final var text = html.wholeText(); | |
| 43 | final var hash = hash32( text, 0, text.length(), SEED ); | |
| 44 | ||
| 45 | if( hash != sHash ) { | |
| 46 | sHash = hash; | |
| 47 | new DocumentChangedEvent( text ).fire(); | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Returns the text that has changed. | |
| 53 | * | |
| 54 | * @return The new document text. | |
| 55 | */ | |
| 56 | public String getDocument() { | |
| 57 | return mText; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Returns the document. | |
| 62 | * | |
| 63 | * @return The value from {@link #getDocument()}. | |
| 64 | */ | |
| 65 | @Override | |
| 66 | public String toString() { | |
| 67 | return getDocument(); | |
| 68 | } | |
| 69 | } | |
| 1 | 70 |
| 52 | 52 | assert text != null; |
| 53 | 53 | assert 1 <= level && level <= 6; |
| 54 | assert offset >= 0; | |
| 54 | assert 0 <= offset; | |
| 55 | 55 | new ParseHeadingEvent( level, text, offset ).fire(); |
| 56 | 56 | } |
| ... | ||
| 73 | 73 | } |
| 74 | 74 | |
| 75 | /** | |
| 76 | * Returns an offset into the document where the heading is found. | |
| 77 | * | |
| 78 | * @return A zero-based document offset. | |
| 79 | */ | |
| 75 | 80 | public int getOffset() { |
| 76 | 81 | return mOffset; |
| 69 | 69 | |
| 70 | 70 | if( trace != null ) { |
| 71 | sb.append( trace.getMessage().trim() ).append( NEWLINE ); | |
| 72 | 71 | stream( trace.getStackTrace() ) |
| 73 | 72 | .takeWhile( StatusEvent::filter ) |
| ... | ||
| 85 | 84 | clazz.contains( "org.renjin." ) || |
| 86 | 85 | clazz.contains( "sun." ) || |
| 86 | clazz.contains( "flexmark." ) || | |
| 87 | 87 | clazz.contains( "java." ); |
| 88 | 88 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.events; | |
| 3 | ||
| 4 | /** | |
| 5 | * Collates information about the word count changing. | |
| 6 | */ | |
| 7 | public class WordCountEvent implements AppEvent { | |
| 8 | /** | |
| 9 | * Number of words in the document. | |
| 10 | */ | |
| 11 | private final int mCount; | |
| 12 | ||
| 13 | private WordCountEvent( final int count ) { | |
| 14 | mCount = count; | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * Publishes an event that indicates the number of words in the document. | |
| 19 | * | |
| 20 | * @param count The approximate number of words in the document. | |
| 21 | */ | |
| 22 | public static void fireWordCountEvent( final int count ) { | |
| 23 | new WordCountEvent( count ).fire(); | |
| 24 | } | |
| 25 | ||
| 26 | public int getCount() { | |
| 27 | return mCount; | |
| 28 | } | |
| 29 | } | |
| 1 | 30 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | ||
| 3 | /** | |
| 4 | * This package contains classes to help with word count. In logographic, | |
| 5 | * or other non-alphabetic languages, word tokenization cannot rely on | |
| 6 | * spaces. Instead, we need to employ a more sophisticated approach using | |
| 7 | * natural language parsing (NLP). | |
| 8 | */ | |
| 9 | package com.keenwrite.heuristics; | |
| 1 | 10 |
| 17 | 17 | */ |
| 18 | 18 | public enum MediaType { |
| 19 | APP_JAVA_OBJECT( | |
| 20 | APPLICATION, "x-java-serialized-object" | |
| 21 | ), | |
| 19 | /* | |
| 20 | * Internal values applied to non-editor tabs. | |
| 21 | */ | |
| 22 | 22 | APP_DOCUMENT_OUTLINE( |
| 23 | 23 | APPLICATION, "x-document-outline" |
| 24 | ), | |
| 25 | APP_DOCUMENT_STATISTICS( | |
| 26 | APPLICATION, "x-document-statistics" | |
| 27 | ), | |
| 28 | ||
| 29 | /* | |
| 30 | * Internal values used to distinguish document outline tabs from editors. | |
| 31 | */ | |
| 32 | APP_JAVA_OBJECT( | |
| 33 | APPLICATION, "x-java-serialized-object" | |
| 24 | 34 | ), |
| 25 | 35 | |
| 36 | /* | |
| 37 | * Standard font types. | |
| 38 | */ | |
| 26 | 39 | FONT_OTF( "otf" ), |
| 27 | 40 | FONT_TTF( "ttf" ), |
| 28 | 41 | |
| 42 | /* | |
| 43 | * Standard image types. | |
| 44 | */ | |
| 29 | 45 | IMAGE_APNG( "apng" ), |
| 30 | 46 | IMAGE_ACES( "aces" ), |
| ... | ||
| 68 | 84 | IMAGE_WMF( "wmf" ), |
| 69 | 85 | |
| 86 | /* | |
| 87 | * Document types for editing or displaying documents, mix of standard and | |
| 88 | * application-specific. | |
| 89 | */ | |
| 70 | 90 | TEXT_HTML( TEXT, "html" ), |
| 71 | 91 | TEXT_MARKDOWN( TEXT, "markdown" ), |
| 72 | 92 | TEXT_PLAIN( TEXT, "plain" ), |
| 73 | 93 | TEXT_R_MARKDOWN( TEXT, "R+markdown" ), |
| 74 | 94 | TEXT_R_XML( TEXT, "R+xml" ), |
| 75 | 95 | TEXT_YAML( TEXT, "yaml" ), |
| 76 | 96 | |
| 97 | /* | |
| 98 | * When all other lights go out. | |
| 99 | */ | |
| 77 | 100 | UNDEFINED( TypeName.UNDEFINED, "undefined" ); |
| 78 | 101 | |
| 1 | package com.keenwrite.outline; | |
| 2 | ||
| 3 | import com.keenwrite.events.Bus; | |
| 4 | import com.keenwrite.events.ParseHeadingEvent; | |
| 5 | import javafx.event.Event; | |
| 6 | import javafx.event.EventDispatchChain; | |
| 7 | import javafx.event.EventDispatcher; | |
| 8 | import javafx.scene.control.TreeCell; | |
| 9 | import javafx.scene.control.TreeItem; | |
| 10 | import javafx.scene.control.TreeView; | |
| 11 | import javafx.scene.input.MouseEvent; | |
| 12 | import javafx.scene.text.Text; | |
| 13 | import javafx.util.Callback; | |
| 14 | import org.greenrobot.eventbus.Subscribe; | |
| 15 | ||
| 16 | import static com.keenwrite.Constants.ICON_SIZE_DEFAULT; | |
| 17 | import static com.keenwrite.events.Bus.register; | |
| 18 | import static com.keenwrite.events.CaretNavigationEvent.fireCaretNavigationEvent; | |
| 19 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.valueOf; | |
| 20 | import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get; | |
| 21 | import static javafx.application.Platform.runLater; | |
| 22 | import static javafx.scene.input.MouseButton.PRIMARY; | |
| 23 | import static javafx.scene.input.MouseEvent.MOUSE_PRESSED; | |
| 24 | ||
| 25 | public class DocumentOutline extends TreeView<ParseHeadingEvent> { | |
| 26 | private TreeItem<ParseHeadingEvent> mCurrent; | |
| 27 | ||
| 28 | /** | |
| 29 | * Registers with the {@link Bus}. | |
| 30 | */ | |
| 31 | public DocumentOutline() { | |
| 32 | register( this ); | |
| 33 | ||
| 34 | // Override double-click to issue a caret navigation event. | |
| 35 | setCellFactory( new Callback<>() { | |
| 36 | @Override | |
| 37 | public TreeCell<ParseHeadingEvent> call( | |
| 38 | TreeView<ParseHeadingEvent> treeView ) { | |
| 39 | TreeCell<ParseHeadingEvent> cell = new TreeCell<>() { | |
| 40 | @Override | |
| 41 | protected void updateItem( ParseHeadingEvent item, boolean empty ) { | |
| 42 | super.updateItem( item, empty ); | |
| 43 | if( empty || item == null ) { | |
| 44 | setText( null ); | |
| 45 | setGraphic( null ); | |
| 46 | } | |
| 47 | else { | |
| 48 | setText( item.toString() ); | |
| 49 | setGraphic( createIcon() ); | |
| 50 | } | |
| 51 | } | |
| 52 | }; | |
| 53 | ||
| 54 | cell.addEventFilter( MOUSE_PRESSED, event -> { | |
| 55 | if( event.getButton() == PRIMARY && event.getClickCount() % 2 == 0 ) { | |
| 56 | fireCaretNavigationEvent( cell.getItem().getOffset() ); | |
| 57 | event.consume(); | |
| 58 | } | |
| 59 | } ); | |
| 60 | ||
| 61 | return cell; | |
| 62 | } | |
| 63 | } ); | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Updates the {@link TreeView} with the given event data. This method will | |
| 68 | * track the most recently added {@link TreeItem} so that the nesting | |
| 69 | * hierarchy reflects the document hierarchy. | |
| 70 | * | |
| 71 | * @param event Represents a document heading to add to the tree. | |
| 72 | */ | |
| 73 | @Subscribe | |
| 74 | public void handle( final ParseHeadingEvent event ) { | |
| 75 | runLater( | |
| 76 | () -> mCurrent = event.isNewOutline() ? clear( event ) : addItem( event ) | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | private TreeItem<ParseHeadingEvent> clear( final ParseHeadingEvent event ) { | |
| 81 | final var root = createTreeItem( event ); | |
| 82 | setRoot( root ); | |
| 83 | setShowRoot( false ); | |
| 84 | return root; | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * This method is called once for every heading in the document. The event | |
| 89 | * data directly corresponds to the sequence of headings in the document. | |
| 90 | * The given event data contains a level that is relative to the last | |
| 91 | * item in the tree. | |
| 92 | * | |
| 93 | * @param next Contains a level value to indicate heading depth. | |
| 94 | */ | |
| 95 | private TreeItem<ParseHeadingEvent> addItem( final ParseHeadingEvent next ) { | |
| 96 | var parent = mCurrent; | |
| 97 | final var item = createTreeItem( next ); | |
| 98 | final var curr = parent.getValue(); | |
| 99 | final var currLevel = curr.getLevel(); | |
| 100 | final var nextLevel = next.getLevel(); | |
| 101 | var deltaLevel = currLevel - nextLevel + 1; | |
| 102 | ||
| 103 | while( deltaLevel > 0 && parent != null ) { | |
| 104 | parent = parent.getParent(); | |
| 105 | deltaLevel--; | |
| 106 | } | |
| 107 | ||
| 108 | if( parent == null ) { | |
| 109 | parent = getRoot(); | |
| 110 | } | |
| 111 | ||
| 112 | parent.getChildren().add( item ); | |
| 113 | ||
| 114 | return item; | |
| 115 | } | |
| 116 | ||
| 117 | private TreeItem<ParseHeadingEvent> createTreeItem( | |
| 118 | final ParseHeadingEvent event ) { | |
| 119 | final var item = new TreeItem<>( event, createIcon() ); | |
| 120 | item.setExpanded( true ); | |
| 121 | return item; | |
| 122 | } | |
| 123 | ||
| 124 | private Text createIcon() { | |
| 125 | return get().createIcon( valueOf( "BOOKMARK" ), ICON_SIZE_DEFAULT ); | |
| 126 | } | |
| 127 | ||
| 128 | private class TreeMouseEventDispatcher implements EventDispatcher { | |
| 129 | private final EventDispatcher mDispatcher; | |
| 130 | ||
| 131 | public TreeMouseEventDispatcher( final EventDispatcher dispatcher ) { | |
| 132 | mDispatcher = dispatcher; | |
| 133 | } | |
| 134 | ||
| 135 | @Override | |
| 136 | public Event dispatchEvent( final Event e, final EventDispatchChain tail ) { | |
| 137 | if( e instanceof MouseEvent ) { | |
| 138 | final var event = (MouseEvent) e; | |
| 139 | if( event.getButton() == PRIMARY && event.getClickCount() >= 2 ) { | |
| 140 | e.consume(); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | return mDispatcher.dispatchEvent( e, tail ); | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 148 | 1 |
| 19 | 19 | |
| 20 | 20 | import static com.keenwrite.events.FileOpenEvent.fireFileOpenEvent; |
| 21 | import static com.keenwrite.events.DocumentChangedEvent.fireDocumentChangedEvent; | |
| 21 | 22 | import static com.keenwrite.events.StatusEvent.clue; |
| 22 | 23 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| ... | ||
| 106 | 107 | */ |
| 107 | 108 | public void render( final String html, final String baseUri ) { |
| 108 | final var doc = CONVERTER.fromJsoup( parse( html ) ); | |
| 109 | final var soup = parse( html ); | |
| 110 | final var doc = CONVERTER.fromJsoup( soup ); | |
| 109 | 111 | final Runnable renderDocument = () -> setDocument( doc, baseUri, XNH ); |
| 110 | 112 | |
| ... | ||
| 118 | 120 | invokeLater( renderDocument ); |
| 119 | 121 | } |
| 122 | ||
| 123 | // When the text changes, let subscribers know. This allows for text | |
| 124 | // analysis to occur on a separate thread. | |
| 125 | fireDocumentChangedEvent( soup ); | |
| 120 | 126 | } |
| 121 | 127 | |
| 134 | 134 | |
| 135 | 135 | /** |
| 136 | * Clears the caches then rerenders the content. | |
| 136 | * Clears the caches then re-renders the content. | |
| 137 | 137 | */ |
| 138 | 138 | public void refresh() { |
| ... | ||
| 183 | 183 | url == null ? "" : format( HTML_STYLESHEET, url ), |
| 184 | 184 | getFontFamily(), |
| 185 | (int) (getFontSize() * (1 + 1 / 3f)), | |
| 185 | toPixels( getFontSize() ), | |
| 186 | 186 | base.isBlank() ? "" : format( HTML_BASE, base ) |
| 187 | 187 | ); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.preview; | |
| 3 | ||
| 4 | import javax.swing.*; | |
| 5 | import java.awt.*; | |
| 6 | import java.util.function.Consumer; | |
| 7 | ||
| 8 | import static java.awt.Scrollbar.VERTICAL; | |
| 9 | import static java.lang.Math.min; | |
| 10 | import static javafx.animation.Interpolator.EASE_BOTH; | |
| 11 | ||
| 12 | /** | |
| 13 | * Responsible for smoothing out the scrolling using an easing algorithm. | |
| 14 | * | |
| 15 | * @deprecated Does not refresh properly, has tearing of large images, and | |
| 16 | * jerks around when dragging the thumb (track). | |
| 17 | */ | |
| 18 | @Deprecated | |
| 19 | public class SmoothScrollPane extends JScrollPane { | |
| 20 | ||
| 21 | public SmoothScrollPane( final Component component ) { | |
| 22 | super( component ); | |
| 23 | setVerticalScrollBarPolicy( VERTICAL_SCROLLBAR_ALWAYS ); | |
| 24 | } | |
| 25 | ||
| 26 | @Override | |
| 27 | public ScrollBar createVerticalScrollBar() { | |
| 28 | return new SmoothScrollBar( VERTICAL ); | |
| 29 | } | |
| 30 | ||
| 31 | private class SmoothScrollBar extends ScrollBar implements Consumer<Integer> { | |
| 32 | private final Animator mAnimator = new Animator( this, () -> { | |
| 33 | // Fails to fix refresh problems when scrolling finishes. This is the | |
| 34 | // reason the class is deprecated. Calling invokeLater helps a little. | |
| 35 | SmoothScrollPane.this.getViewport().revalidate(); | |
| 36 | revalidate(); | |
| 37 | repaint(); | |
| 38 | } ); | |
| 39 | ||
| 40 | public SmoothScrollBar( final int orientation ) { | |
| 41 | super( orientation ); | |
| 42 | } | |
| 43 | ||
| 44 | @Override | |
| 45 | public void setValue( final int nPos ) { | |
| 46 | final var oPos = getModel().getValue(); | |
| 47 | ||
| 48 | mAnimator.stop(); | |
| 49 | mAnimator.restart( oPos, nPos, 250 ); | |
| 50 | new Thread( mAnimator ).start(); | |
| 51 | } | |
| 52 | ||
| 53 | @Override | |
| 54 | public void accept( final Integer nPos ) { | |
| 55 | super.setValue( nPos ); | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | private static class Animator implements Runnable { | |
| 60 | private final Consumer<Integer> mAction; | |
| 61 | private final Runnable mComplete; | |
| 62 | ||
| 63 | private int mOldPos; | |
| 64 | private int mNewPos; | |
| 65 | private long mBegan; | |
| 66 | private long mEnded; | |
| 67 | private volatile boolean mRunning; | |
| 68 | ||
| 69 | public Animator( final Consumer<Integer> action, final Runnable complete ) { | |
| 70 | mAction = action; | |
| 71 | mComplete = complete; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * @param oPos Old scroll bar position. | |
| 76 | * @param nPos New scroll bar position. | |
| 77 | * @param time Total time to complete the scroll event (in milliseconds). | |
| 78 | */ | |
| 79 | public void restart( final int oPos, final int nPos, final int time ) { | |
| 80 | mOldPos = oPos; | |
| 81 | mNewPos = nPos; | |
| 82 | mBegan = System.nanoTime(); | |
| 83 | mEnded = time * 1_000_000L; | |
| 84 | mRunning = true; | |
| 85 | } | |
| 86 | ||
| 87 | public void stop() { | |
| 88 | mRunning = false; | |
| 89 | } | |
| 90 | ||
| 91 | @Override | |
| 92 | public void run() { | |
| 93 | double ratio; | |
| 94 | ||
| 95 | do { | |
| 96 | ratio = min( (double) (System.nanoTime() - mBegan) / mEnded, 1.0 ); | |
| 97 | final int nPos = EASE_BOTH.interpolate( mOldPos, mNewPos, ratio ); | |
| 98 | ||
| 99 | mAction.accept( nPos ); | |
| 100 | } while( ratio <= 1 && mRunning ); | |
| 101 | ||
| 102 | mComplete.run(); | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | 1 |
| 44 | 44 | |
| 45 | 45 | final var processor = switch( context.getFileType() ) { |
| 46 | //case RMARKDOWN -> createRProcessor( successor ); | |
| 47 | 46 | case SOURCE, RMARKDOWN -> createMarkdownProcessor( successor ); |
| 48 | case RXML -> createRXMLProcessor( successor ); | |
| 49 | case XML -> createXMLProcessor( successor ); | |
| 47 | case RXML -> createRXmlProcessor( successor ); | |
| 48 | case XML -> createXmlProcessor( successor ); | |
| 50 | 49 | default -> createPreformattedProcessor( successor ); |
| 51 | 50 | }; |
| ... | ||
| 102 | 101 | final Processor<String> successor ) { |
| 103 | 102 | return new DefinitionProcessor( successor, getProcessorContext() ); |
| 104 | } | |
| 105 | ||
| 106 | private Processor<String> createRProcessor( | |
| 107 | final Processor<String> successor ) { | |
| 108 | return MarkdownProcessor.create( successor, getProcessorContext() ); | |
| 109 | 103 | } |
| 110 | 104 | |
| 111 | protected Processor<String> createRXMLProcessor( | |
| 105 | protected Processor<String> createRXmlProcessor( | |
| 112 | 106 | final Processor<String> successor ) { |
| 113 | final var xmlp = new XmlProcessor( successor, getProcessorContext() ); | |
| 114 | return createRProcessor( xmlp ); | |
| 107 | final var context = getProcessorContext(); | |
| 108 | final var rp = MarkdownProcessor.create( successor, context ); | |
| 109 | return new XmlProcessor( rp, context ); | |
| 115 | 110 | } |
| 116 | 111 | |
| 117 | private Processor<String> createXMLProcessor( | |
| 112 | private Processor<String> createXmlProcessor( | |
| 118 | 113 | final Processor<String> successor ) { |
| 119 | 114 | final var xmlp = new XmlProcessor( successor, getProcessorContext() ); |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| 4 | import net.sf.saxon.Configuration; | |
| 4 | 5 | import net.sf.saxon.TransformerFactoryImpl; |
| 6 | import net.sf.saxon.om.IgnorableSpaceStrippingRule; | |
| 5 | 7 | import net.sf.saxon.trans.XPathException; |
| 6 | 8 | |
| ... | ||
| 18 | 20 | import java.nio.file.Paths; |
| 19 | 21 | |
| 22 | import static com.keenwrite.events.StatusEvent.clue; | |
| 20 | 23 | import static javax.xml.stream.XMLInputFactory.newInstance; |
| 21 | 24 | import static net.sf.saxon.tree.util.ProcInstParser.getPseudoAttribute; |
| ... | ||
| 36 | 39 | |
| 37 | 40 | private final XMLInputFactory mXmlInputFactory = newInstance(); |
| 41 | private final Configuration mConfiguration = new Configuration(); | |
| 38 | 42 | private final TransformerFactory mTransformerFactory = |
| 39 | new TransformerFactoryImpl(); | |
| 43 | new TransformerFactoryImpl(mConfiguration); | |
| 40 | 44 | private Transformer mTransformer; |
| 41 | 45 | |
| ... | ||
| 59 | 63 | // Bubble problems up to the user interface, rather than standard error. |
| 60 | 64 | mTransformerFactory.setErrorListener( this ); |
| 65 | final var options = mConfiguration.getParseOptions(); | |
| 66 | options.setSpaceStrippingRule( IgnorableSpaceStrippingRule.getInstance() ); | |
| 61 | 67 | } |
| 62 | 68 | |
| ... | ||
| 72 | 78 | return text.isEmpty() ? text : transform( text ); |
| 73 | 79 | } catch( final Exception ex ) { |
| 80 | clue( ex ); | |
| 74 | 81 | throw new RuntimeException( ex ); |
| 75 | 82 | } |
| ... | ||
| 85 | 92 | */ |
| 86 | 93 | private String transform( final String text ) throws Exception { |
| 87 | // Extract the XML stylesheet processing instruction. | |
| 88 | final String template = getXsltFilename( text ); | |
| 89 | final Path xsl = getXslPath( template ); | |
| 90 | ||
| 91 | 94 | try( |
| 92 | final StringWriter output = new StringWriter( text.length() ); | |
| 93 | final StringReader input = new StringReader( text ) ) { | |
| 95 | final var output = new StringWriter( text.length() ); | |
| 96 | final var input = new StringReader( text ) ) { | |
| 97 | // Extract the XML stylesheet processing instruction. | |
| 98 | final var template = getXsltFilename( text ); | |
| 99 | final var xsl = getXslPath( template ); | |
| 94 | 100 | |
| 95 | 101 | // TODO: Use FileWatchService |
| ... | ||
| 155 | 161 | private String getXsltFilename( final String xml ) |
| 156 | 162 | throws XMLStreamException, XPathException { |
| 157 | String result = ""; | |
| 163 | var result = ""; | |
| 158 | 164 | |
| 159 | try( final StringReader sr = new StringReader( xml ) ) { | |
| 160 | final XMLEventReader reader = createXmlEventReader( sr ); | |
| 161 | boolean found = false; | |
| 162 | int count = 0; | |
| 165 | try( final var sr = new StringReader( xml ) ) { | |
| 166 | final var reader = createXmlEventReader( sr ); | |
| 167 | var found = false; | |
| 168 | var count = 0; | |
| 163 | 169 | |
| 164 | 170 | // If the processing instruction wasn't found in the first 10 lines, |
| ... | ||
| 198 | 204 | @Override |
| 199 | 205 | public void warning( final TransformerException ex ) { |
| 200 | throw new RuntimeException( ex ); | |
| 206 | clue( ex ); | |
| 201 | 207 | } |
| 202 | 208 | |
| 203 | 209 | /** |
| 204 | 210 | * Called when the XSL transformer issues an error. |
| 205 | 211 | * |
| 206 | 212 | * @param ex The problem the transformer encountered. |
| 207 | 213 | */ |
| 208 | 214 | @Override |
| 209 | 215 | public void error( final TransformerException ex ) { |
| 210 | throw new RuntimeException( ex ); | |
| 216 | clue( ex ); | |
| 211 | 217 | } |
| 212 | 218 | |
| 213 | 219 | /** |
| 214 | 220 | * Called when the XSL transformer issues a fatal error, which is probably |
| 215 | * a bit over-dramatic a method name. | |
| 221 | * a bit over-dramatic for a method name. | |
| 216 | 222 | * |
| 217 | 223 | * @param ex The problem the transformer encountered. |
| 218 | 224 | */ |
| 219 | 225 | @Override |
| 220 | 226 | public void fatalError( final TransformerException ex ) { |
| 221 | throw new RuntimeException( ex ); | |
| 227 | clue( ex ); | |
| 222 | 228 | } |
| 223 | 229 | } |
| 35 | 35 | super( successor ); |
| 36 | 36 | |
| 37 | final var extensions = new ArrayList<Extension>(); | |
| 38 | init( extensions, context ); | |
| 37 | final var extensions = createExtensions( context ); | |
| 39 | 38 | |
| 40 | 39 | mParser = Parser.builder().extensions( extensions ).build(); |
| ... | ||
| 47 | 46 | * HTML entities. |
| 48 | 47 | * |
| 49 | * @param extensions A {@link List} of {@link Extension} instances that | |
| 50 | * change the {@link Parser}'s behaviour. | |
| 51 | * @param context The context that subclasses use to configure custom | |
| 52 | * extension behaviour. | |
| 48 | * @param context The context that subclasses use to configure custom | |
| 49 | * extension behaviour. | |
| 50 | * @return A {@link List} of {@link Extension} instances that change the | |
| 51 | * {@link Parser}'s behaviour. | |
| 53 | 52 | */ |
| 54 | void init( | |
| 55 | final List<Extension> extensions, final ProcessorContext context ) { | |
| 53 | List<Extension> createExtensions( final ProcessorContext context ) { | |
| 54 | final var extensions = new ArrayList<Extension>(); | |
| 55 | ||
| 56 | 56 | extensions.add( DefinitionExtension.create() ); |
| 57 | 57 | extensions.add( StrikethroughSubscriptExtension.create() ); |
| 58 | 58 | extensions.add( SuperscriptExtension.create() ); |
| 59 | 59 | extensions.add( TablesExtension.create() ); |
| 60 | 60 | extensions.add( TypographicExtension.create() ); |
| 61 | ||
| 62 | return extensions; | |
| 61 | 63 | } |
| 62 | 64 | |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.io.MediaType; |
| 5 | import com.keenwrite.processors.DefinitionProcessor; | |
| 5 | 6 | import com.keenwrite.processors.Processor; |
| 6 | 7 | import com.keenwrite.processors.ProcessorContext; |
| 7 | import com.keenwrite.processors.markdown.extensions.DocumentOutlineExtension; | |
| 8 | import com.keenwrite.processors.markdown.extensions.FencedBlockExtension; | |
| 9 | import com.keenwrite.processors.markdown.extensions.ImageLinkExtension; | |
| 10 | import com.keenwrite.processors.markdown.extensions.CaretExtension; | |
| 8 | import com.keenwrite.processors.markdown.extensions.*; | |
| 11 | 9 | import com.keenwrite.processors.markdown.extensions.r.RExtension; |
| 12 | 10 | import com.keenwrite.processors.markdown.extensions.tex.TeXExtension; |
| 13 | 11 | import com.keenwrite.processors.r.RProcessor; |
| 14 | 12 | import com.vladsch.flexmark.util.misc.Extension; |
| 15 | 13 | |
| 14 | import java.util.ArrayList; | |
| 16 | 15 | import java.util.List; |
| 17 | 16 | |
| ... | ||
| 48 | 47 | * formats can be edited. |
| 49 | 48 | * |
| 50 | * @param extensions {@link List} of extensions invoked when parsing Markdown. | |
| 51 | * @param context Contains necessary information needed to create | |
| 52 | * extensions used by the Markdown parser. | |
| 49 | * @param context Contains necessary information needed to create | |
| 50 | * extensions used by the Markdown parser. | |
| 51 | * @return {@link List} of extensions invoked when parsing Markdown. | |
| 53 | 52 | */ |
| 54 | 53 | @Override |
| 55 | void init( | |
| 56 | final List<Extension> extensions, final ProcessorContext context ) { | |
| 54 | List<Extension> createExtensions( final ProcessorContext context ) { | |
| 57 | 55 | final var editorFile = context.getDocumentPath(); |
| 58 | 56 | final var mediaType = MediaType.valueFrom( editorFile ); |
| 59 | 57 | final Processor<String> processor; |
| 58 | final List<Extension> extensions = new ArrayList<>(); | |
| 60 | 59 | |
| 61 | 60 | if( mediaType == TEXT_R_MARKDOWN || mediaType == TEXT_R_XML ) { |
| 62 | 61 | final var rProcessor = new RProcessor( context ); |
| 63 | 62 | extensions.add( RExtension.create( rProcessor, context ) ); |
| 64 | 63 | processor = rProcessor; |
| 65 | 64 | } |
| 66 | 65 | else { |
| 67 | processor = IDENTITY; | |
| 66 | processor = new DefinitionProcessor( IDENTITY, context ); | |
| 68 | 67 | } |
| 69 | 68 | |
| 70 | 69 | // Add typographic, table, strikethrough, and similar extensions. |
| 71 | super.init( extensions, context ); | |
| 70 | extensions.addAll( super.createExtensions( context ) ); | |
| 72 | 71 | |
| 73 | 72 | extensions.add( ImageLinkExtension.create( context ) ); |
| 74 | extensions.add( TeXExtension.create( context, processor ) ); | |
| 75 | extensions.add( FencedBlockExtension.create( context ) ); | |
| 73 | extensions.add( TeXExtension.create( processor, context ) ); | |
| 74 | extensions.add( FencedBlockExtension.create( processor ) ); | |
| 76 | 75 | extensions.add( CaretExtension.create( context ) ); |
| 77 | 76 | extensions.add( DocumentOutlineExtension.create( processor ) ); |
| 77 | return extensions; | |
| 78 | 78 | } |
| 79 | 79 | } |
| 41 | 41 | |
| 42 | 42 | private class HeadingNodePostProcessor extends NodePostProcessor { |
| 43 | ||
| 44 | 43 | @Override |
| 45 | 44 | public void process( |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.processors.DefinitionProcessor; |
| 5 | import com.keenwrite.processors.ProcessorContext; | |
| 5 | import com.keenwrite.processors.Processor; | |
| 6 | 6 | import com.keenwrite.processors.markdown.MarkdownProcessor; |
| 7 | 7 | import com.vladsch.flexmark.ast.FencedCodeBlock; |
| ... | ||
| 20 | 20 | import static com.keenwrite.Constants.DIAGRAM_SERVER_NAME; |
| 21 | 21 | import static com.keenwrite.events.StatusEvent.clue; |
| 22 | import static com.keenwrite.processors.IdentityProcessor.IDENTITY; | |
| 23 | 22 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 24 | 23 | import static com.vladsch.flexmark.html.renderer.LinkType.LINK; |
| ... | ||
| 36 | 35 | private final static int DIAGRAM_STYLE_LEN = DIAGRAM_STYLE.length(); |
| 37 | 36 | |
| 38 | private final DefinitionProcessor mProcessor; | |
| 37 | private final Processor<String> mProcessor; | |
| 39 | 38 | |
| 40 | public FencedBlockExtension( final ProcessorContext context ) { | |
| 41 | assert context != null; | |
| 42 | mProcessor = new DefinitionProcessor( IDENTITY, context ); | |
| 39 | public FencedBlockExtension( final Processor<String> processor ) { | |
| 40 | assert processor != null; | |
| 41 | mProcessor = processor; | |
| 43 | 42 | } |
| 44 | 43 | |
| ... | ||
| 57 | 56 | * </p> |
| 58 | 57 | * |
| 59 | * @param context Used to create a new {@link DefinitionProcessor}. | |
| 58 | * @param processor Used to pre-process the text. | |
| 60 | 59 | * @return A new {@link FencedBlockExtension} capable of shunting ASCII |
| 61 | 60 | * diagrams to a service for conversion to SVG. |
| 62 | 61 | */ |
| 63 | public static FencedBlockExtension create( final ProcessorContext context ) { | |
| 64 | return new FencedBlockExtension( context ); | |
| 62 | public static FencedBlockExtension create( | |
| 63 | final Processor<String> processor ) { | |
| 64 | return new FencedBlockExtension( processor ); | |
| 65 | 65 | } |
| 66 | 66 | |
| ... | ||
| 95 | 95 | final var type = style.substring( DIAGRAM_STYLE_LEN ); |
| 96 | 96 | final var content = node.getContentChars().normalizeEOL(); |
| 97 | final var text = FencedBlockExtension.this.mProcessor.apply( content ); | |
| 97 | final var text = mProcessor.apply( content ); | |
| 98 | 98 | final var encoded = encode( text ); |
| 99 | 99 | final var source = format( |
| 36 | 36 | private final ExportFormat mExportFormat; |
| 37 | 37 | |
| 38 | private ImageLinkExtension( @NotNull final ProcessorContext context ) { | |
| 38 | private ImageLinkExtension( | |
| 39 | @NotNull final ProcessorContext context ) { | |
| 39 | 40 | mBaseDir = context.getBaseDir(); |
| 40 | 41 | mWorkspace = context.getWorkspace(); |
| ... | ||
| 56 | 57 | public void extend( |
| 57 | 58 | @NotNull final Builder builder, @NotNull final String rendererType ) { |
| 58 | builder.linkResolverFactory( new Factory() ); | |
| 59 | builder.linkResolverFactory( new ResolverFactory() ); | |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | private class Factory extends IndependentLinkResolverFactory { | |
| 62 | private final class ResolverFactory extends IndependentLinkResolverFactory { | |
| 62 | 63 | @Override |
| 63 | 64 | public @NotNull LinkResolver apply( |
| ... | ||
| 77 | 78 | @NotNull final LinkResolverBasicContext context, |
| 78 | 79 | @NotNull final ResolvedLink link ) { |
| 79 | return node instanceof Image ? resolve( link ) : link; | |
| 80 | return node instanceof Image ? forImage( link ) : link; | |
| 80 | 81 | } |
| 81 | 82 | |
| ... | ||
| 93 | 94 | * @return The {@link ResolvedLink} instance used to render the link. |
| 94 | 95 | */ |
| 95 | private ResolvedLink resolve( final ResolvedLink link ) { | |
| 96 | private ResolvedLink forImage( final ResolvedLink link ) { | |
| 96 | 97 | var uri = link.getUrl(); |
| 97 | 98 | final var protocol = getProtocol( uri ); |
| 36 | 36 | */ |
| 37 | 37 | public final class RExtension implements ParserExtension { |
| 38 | private final InlineParserFactory FACTORY = CustomParser::new; | |
| 38 | private final InlineParserFactory INLINE_FACTORY = InlineParser::new; | |
| 39 | 39 | private final RProcessor mProcessor; |
| 40 | 40 | private final BaseMarkdownProcessor mMarkdownProcessor; |
| ... | ||
| 57 | 57 | @Override |
| 58 | 58 | public void extend( final Builder builder ) { |
| 59 | builder.customInlineParserFactory( FACTORY ); | |
| 59 | builder.customInlineParserFactory( INLINE_FACTORY ); | |
| 60 | 60 | } |
| 61 | 61 | |
| ... | ||
| 76 | 76 | * </p> |
| 77 | 77 | */ |
| 78 | private class CustomParser extends InlineParserImpl { | |
| 79 | private CustomParser( | |
| 78 | private class InlineParser extends InlineParserImpl { | |
| 79 | private InlineParser( | |
| 80 | 80 | final DataHolder options, |
| 81 | 81 | final BitSet specialCharacters, |
| ... | ||
| 97 | 97 | * changes the behaviour to retain R code snippets, identified by |
| 98 | 98 | * {@link RSigilOperator#PREFIX}, so that subsequent processing can |
| 99 | * invoke R. If other languages are added, the {@link CustomParser} will | |
| 99 | * invoke R. If other languages are added, the {@link InlineParser} will | |
| 100 | 100 | * have to be rewritten to identify more than merely R. |
| 101 | 101 | * |
| 27 | 27 | |
| 28 | 28 | /** |
| 29 | * Responsible for pre-parsing the input. | |
| 30 | */ | |
| 31 | private final Processor<String> mProcessor; | |
| 32 | ||
| 33 | /** | |
| 29 | 34 | * Controls how the node renderer produces TeX code within HTML output. |
| 30 | 35 | */ |
| 31 | 36 | private final ExportFormat mExportFormat; |
| 32 | ||
| 33 | private final Processor<String> mProcessor; | |
| 34 | 37 | |
| 35 | 38 | private TeXExtension( |
| 36 | final ProcessorContext context, final Processor<String> processor ) { | |
| 37 | mExportFormat = context.getExportFormat(); | |
| 39 | final Processor<String> processor, final ProcessorContext context ) { | |
| 38 | 40 | mProcessor = processor; |
| 41 | mExportFormat = context.getExportFormat(); | |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | /** |
| 42 | 45 | * Creates an extension capable of handling delimited TeX code in Markdown. |
| 43 | 46 | * |
| 44 | 47 | * @return The new {@link TeXExtension}, never {@code null}. |
| 45 | 48 | */ |
| 46 | 49 | public static TeXExtension create( |
| 47 | final ProcessorContext context, final Processor<String> processor ) { | |
| 48 | return new TeXExtension( context, processor ); | |
| 50 | final Processor<String> processor, final ProcessorContext context ) { | |
| 51 | return new TeXExtension( processor, context ); | |
| 49 | 52 | } |
| 50 | 53 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * Redistribution and use in source and binary forms, with or without | |
| 4 | * modification, are permitted provided that the following conditions are met: | |
| 5 | * | |
| 6 | * o Redistributions of source code must retain the above copyright | |
| 7 | * notice, this list of conditions and the following disclaimer. | |
| 8 | * | |
| 9 | * o Redistributions in binary form must reproduce the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer in the | |
| 11 | * documentation and/or other materials provided with the distribution. | |
| 12 | * | |
| 13 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 14 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 15 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 16 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 17 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 18 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 19 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 20 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 21 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 22 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 23 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 24 | */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 25 | 2 | |
| 26 | 3 | /** |
| 27 | * This package defines interfaces for spell checking implementations. | |
| 4 | * This package contains interfaces for spell checking implementations. | |
| 28 | 5 | */ |
| 29 | 6 | package com.keenwrite.spelling.api; |
| 30 | 30 | public class SymSpellSpeller implements SpellChecker { |
| 31 | 31 | private final BreakIterator mBreakIterator = BreakIterator.getWordInstance(); |
| 32 | ||
| 33 | 32 | private final SymSpell mSymSpell; |
| 34 | 33 | |
| ... | ||
| 53 | 52 | |
| 54 | 53 | private static SpellChecker forLexicon( |
| 55 | final Collection<String> lexiconWords ) { | |
| 54 | final Collection<String> lexiconWords ) { | |
| 56 | 55 | assert lexiconWords != null && !lexiconWords.isEmpty(); |
| 57 | 56 | |
| 58 | 57 | final var builder = new SymSpellBuilder() |
| 59 | .setLexiconWords( lexiconWords ); | |
| 58 | .setLexiconWords( lexiconWords ); | |
| 60 | 59 | |
| 61 | 60 | return new SymSpellSpeller( builder.build() ); |
| ... | ||
| 96 | 95 | @Override |
| 97 | 96 | public void proofread( |
| 98 | final String text, final SpellCheckListener consumer ) { | |
| 97 | final String text, final SpellCheckListener consumer ) { | |
| 99 | 98 | assert text != null; |
| 100 | 99 | assert consumer != null; |
| 101 | 100 | |
| 102 | 101 | mBreakIterator.setText( text ); |
| 103 | 102 | |
| 104 | 103 | int boundaryIndex = mBreakIterator.first(); |
| 105 | 104 | int previousIndex = 0; |
| 106 | 105 | |
| 107 | 106 | while( boundaryIndex != BreakIterator.DONE ) { |
| 108 | final var lex = text.substring( previousIndex, boundaryIndex ) | |
| 109 | .toLowerCase(); | |
| 107 | final var lex = | |
| 108 | text.substring( previousIndex, boundaryIndex ).toLowerCase(); | |
| 110 | 109 | |
| 111 | 110 | // Get the lexeme for the possessive. |
| ... | ||
| 122 | 121 | } |
| 123 | 122 | |
| 124 | @SuppressWarnings("SameParameterValue") | |
| 123 | @SuppressWarnings( "SameParameterValue" ) | |
| 125 | 124 | private static Collection<String> readLexicon( final String filename ) |
| 126 | throws Exception { | |
| 125 | throws Exception { | |
| 127 | 126 | final var path = '/' + LEXICONS_DIRECTORY + '/' + filename; |
| 128 | 127 | |
| 129 | 128 | try( final var resource = |
| 130 | SymSpellSpeller.class.getResourceAsStream( path ) ) { | |
| 129 | SymSpellSpeller.class.getResourceAsStream( path ) ) { | |
| 131 | 130 | if( resource == null ) { |
| 132 | 131 | throw new MissingFileException( path ); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | * | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 14 | * | |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 26 | */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 27 | 2 | |
| 28 | 3 | /** |
| 18 | 18 | import java.util.List; |
| 19 | 19 | |
| 20 | import static com.keenwrite.Constants.ACTION_PREFIX; | |
| 20 | 21 | import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get; |
| 21 | 22 | import static javafx.scene.input.KeyCombination.valueOf; |
| ... | ||
| 147 | 148 | /** |
| 148 | 149 | * Sets the text, icon, and accelerator for a given action identifier. |
| 149 | * See the "App.action" entries in the messages properties file for details. | |
| 150 | * See the messages properties file for details. | |
| 150 | 151 | * |
| 151 | 152 | * @param id The identifier to look up in the properties file. |
| 152 | 153 | * @return An instance of {@link Builder} that can be built into an |
| 153 | 154 | * instance of {@link Action}. |
| 154 | 155 | */ |
| 155 | 156 | public Builder setId( final String id ) { |
| 156 | final var prefix = "App.action." + id + "."; | |
| 157 | final var prefix = ACTION_PREFIX + id + "."; | |
| 157 | 158 | final var text = prefix + "text"; |
| 158 | 159 | final var icon = prefix + "icon"; |
| 366 | 366 | } |
| 367 | 367 | |
| 368 | public void view‿statistics() { | |
| 369 | getMainPane().viewStatistics(); | |
| 370 | } | |
| 371 | ||
| 368 | 372 | public void view‿menubar() { |
| 369 | 373 | getMainScene().toggleMenuBar(); |
| 120 | 120 | addAction( "view.preview", e -> actions.view‿preview() ), |
| 121 | 121 | addAction( "view.outline", e -> actions.view‿outline() ), |
| 122 | addAction( "view.statistics", e-> actions.view‿statistics() ), | |
| 122 | 123 | SEPARATOR_ACTION, |
| 123 | 124 | addAction( "view.menubar", e -> actions.view‿menubar() ), |
| 60 | 60 | "Dialog.file.choose.open.title" ); |
| 61 | 61 | final var list = dialog.showOpenMultipleDialog( mParent ); |
| 62 | final List<java.io.File> selected = list == null ? List.of() : list; | |
| 62 | final List<File> selected = list == null ? List.of() : list; | |
| 63 | 63 | final var files = new ArrayList<File>( selected.size() ); |
| 64 | 64 | |
| ... | ||
| 109 | 109 | /** |
| 110 | 110 | * Opens a new {@link FileChooser} at the previously selected directory. |
| 111 | * If the initial directory is missing, this will attempt to default to | |
| 112 | * the user's home directory. If the home directory is missing, this will | |
| 113 | * use whatever JavaFX chooses for the initial directory. Without such an | |
| 114 | * intervention, an {@link IllegalArgumentException} would be thrown. | |
| 111 | 115 | * |
| 112 | 116 | * @param key Message key from resource bundle. |
| 113 | 117 | * @return {@link FileChooser} GUI allowing the user to pick a file. |
| 114 | 118 | */ |
| 115 | 119 | private FileChooser createFileChooser( final String key ) { |
| 120 | final var prefDir = mDirectory.getValue(); | |
| 121 | final var openDir = prefDir.isDirectory() ? prefDir : USER_DIRECTORY; | |
| 116 | 122 | final var chooser = new FileChooser(); |
| 117 | 123 | |
| 118 | 124 | chooser.setTitle( get( key ) ); |
| 119 | 125 | chooser.getExtensionFilters().addAll( createExtensionFilters() ); |
| 120 | chooser.setInitialDirectory( mDirectory.getValue() ); | |
| 126 | chooser.setInitialDirectory( openDir.isDirectory() ? openDir : null ); | |
| 121 | 127 | |
| 122 | 128 | return chooser; |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. | |
| 2 | * | |
| 3 | * All rights reserved. | |
| 4 | * | |
| 5 | * Redistribution and use in source and binary forms, with or without | |
| 6 | * modification, are permitted provided that the following conditions are met: | |
| 7 | * | |
| 8 | * o Redistributions of source code must retain the above copyright | |
| 9 | * notice, this list of conditions and the following disclaimer. | |
| 10 | * | |
| 11 | * o Redistributions in binary form must reproduce the above copyright | |
| 12 | * notice, this list of conditions and the following disclaimer in the | |
| 13 | * documentation and/or other materials provided with the distribution. | |
| 14 | * | |
| 15 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 16 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 17 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 18 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 19 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 20 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 21 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 22 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 23 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 25 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 26 | */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 27 | 2 | |
| 28 | 3 | /** |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.ui.heuristics; | |
| 3 | ||
| 4 | import com.keenwrite.events.DocumentChangedEvent; | |
| 5 | import com.keenwrite.preferences.Workspace; | |
| 6 | import com.keenwrite.preview.HtmlPanel; | |
| 7 | import com.keenwrite.util.MurmurHash; | |
| 8 | import com.whitemagicsoftware.wordcount.Tokenizer; | |
| 9 | import com.whitemagicsoftware.wordcount.TokenizerException; | |
| 10 | import com.whitemagicsoftware.wordcount.TokenizerFactory; | |
| 11 | import javafx.beans.property.IntegerProperty; | |
| 12 | import javafx.beans.property.SimpleIntegerProperty; | |
| 13 | import javafx.beans.property.SimpleStringProperty; | |
| 14 | import javafx.beans.property.StringProperty; | |
| 15 | import javafx.collections.ObservableList; | |
| 16 | import javafx.collections.transformation.SortedList; | |
| 17 | import javafx.scene.control.TableColumn; | |
| 18 | import javafx.scene.control.TableView; | |
| 19 | import org.greenrobot.eventbus.Subscribe; | |
| 20 | import org.jsoup.Jsoup; | |
| 21 | ||
| 22 | import java.util.Locale; | |
| 23 | ||
| 24 | import static com.keenwrite.events.Bus.register; | |
| 25 | import static com.keenwrite.events.StatusEvent.clue; | |
| 26 | import static com.keenwrite.events.WordCountEvent.fireWordCountEvent; | |
| 27 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_LANGUAGE_LOCALE; | |
| 28 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_UI_FONT_EDITOR_NAME; | |
| 29 | import static java.lang.String.format; | |
| 30 | import static java.util.Locale.ENGLISH; | |
| 31 | import static javafx.application.Platform.runLater; | |
| 32 | import static javafx.collections.FXCollections.observableArrayList; | |
| 33 | ||
| 34 | /** | |
| 35 | * Responsible for displaying document statistics, such as word count and | |
| 36 | * word frequency. | |
| 37 | */ | |
| 38 | public final class DocumentStatistics | |
| 39 | extends TableView<DocumentStatistics.StatEntry> { | |
| 40 | /** | |
| 41 | * Parses documents into word counts. | |
| 42 | */ | |
| 43 | private static Tokenizer sTokenizer = createTokenizer( ENGLISH ); | |
| 44 | ||
| 45 | private final ObservableList<StatEntry> mItems = observableArrayList(); | |
| 46 | ||
| 47 | /** | |
| 48 | * Creates a new observer of document change events that will gather and | |
| 49 | * display document statistics (e.g., word counts). | |
| 50 | * | |
| 51 | * @param workspace Settings used to configure the statistics engine. | |
| 52 | */ | |
| 53 | public DocumentStatistics( final Workspace workspace ) { | |
| 54 | final var sortedItems = new SortedList<>( mItems ); | |
| 55 | sortedItems.comparatorProperty().bind( comparatorProperty() ); | |
| 56 | setItems( sortedItems ); | |
| 57 | ||
| 58 | initView(); | |
| 59 | initListeners( workspace ); | |
| 60 | register( this ); | |
| 61 | ||
| 62 | final var fontName = workspace.stringProperty( KEY_UI_FONT_EDITOR_NAME ); | |
| 63 | ||
| 64 | fontName.addListener( | |
| 65 | ( c, o, n ) -> { | |
| 66 | if( n != null ) { | |
| 67 | setFontFamily( n ); | |
| 68 | } | |
| 69 | } | |
| 70 | ); | |
| 71 | ||
| 72 | setFontFamily( fontName.getValue() ); | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * Called when the hashcode for the current document changes. This happens | |
| 77 | * when non-collapsable-whitespace is added to the document. When the | |
| 78 | * document is sent to {@link HtmlPanel} for rendering, the parsed | |
| 79 | * {@link Jsoup} document is converted to text. If that text differs | |
| 80 | * (using {@link MurmurHash}), then this method is called. The implication | |
| 81 | * is that all variables and executable statements have been replaced. | |
| 82 | * An event bus subscriber is used so that text processing occurs outside | |
| 83 | * of the UI processing threads. | |
| 84 | * | |
| 85 | * @param event Container for the document text that has changed. | |
| 86 | */ | |
| 87 | @Subscribe | |
| 88 | public void handle( final DocumentChangedEvent event ) { | |
| 89 | try { | |
| 90 | final var tokens = sTokenizer.tokenize( event.getDocument() ); | |
| 91 | final var sum = new int[]{0}; | |
| 92 | ||
| 93 | runLater( () -> { | |
| 94 | mItems.clear(); | |
| 95 | tokens.forEach( ( k, v ) -> { | |
| 96 | final var count = v[ 0 ]; | |
| 97 | if( count > 2 ) { | |
| 98 | mItems.add( new StatEntry( k, count ) ); | |
| 99 | } | |
| 100 | sum[ 0 ] += count; | |
| 101 | } ); | |
| 102 | ||
| 103 | fireWordCountEvent( sum[ 0 ] ); | |
| 104 | } ); | |
| 105 | ||
| 106 | } catch( final TokenizerException ex ) { | |
| 107 | clue( ex ); | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | @SuppressWarnings( "unchecked" ) | |
| 112 | private void initView() { | |
| 113 | final TableColumn<StatEntry, String> colWord = createColumn( "Word" ); | |
| 114 | final TableColumn<StatEntry, Number> colCount = createColumn( "Count" ); | |
| 115 | ||
| 116 | colWord.setCellValueFactory( stat -> stat.getValue().wordProperty() ); | |
| 117 | colCount.setCellValueFactory( stat -> stat.getValue().tallyProperty() ); | |
| 118 | colCount.setComparator( colCount.getComparator().reversed() ); | |
| 119 | ||
| 120 | final var columns = getColumns(); | |
| 121 | columns.add( colWord ); | |
| 122 | columns.add( colCount ); | |
| 123 | ||
| 124 | setMaxWidth( Double.MAX_VALUE ); | |
| 125 | setPrefWidth( 128 ); | |
| 126 | setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY ); | |
| 127 | getSortOrder().setAll( colCount, colWord ); | |
| 128 | ||
| 129 | getStyleClass().add( "" ); | |
| 130 | } | |
| 131 | ||
| 132 | private void initListeners( final Workspace workspace ) { | |
| 133 | final var property = workspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 134 | property.addListener( | |
| 135 | ( c, o, n ) -> sTokenizer = createTokenizer( property.toLocale() ) | |
| 136 | ); | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Creates a tokenizer for English text (can handle most Latin languages). | |
| 141 | * | |
| 142 | * @return An English-based tokenizer for counting words. | |
| 143 | */ | |
| 144 | private static Tokenizer createTokenizer( final Locale language ) { | |
| 145 | return TokenizerFactory.create( language ); | |
| 146 | } | |
| 147 | ||
| 148 | private <E, T> TableColumn<E, T> createColumn( final String key ) { | |
| 149 | return new TableColumn<>( key ); | |
| 150 | } | |
| 151 | ||
| 152 | private void setFontFamily( final String value ) { | |
| 153 | runLater( () -> setStyle( format( "-fx-font-family:'%s';", value ) ) ); | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Represents the number of times a word appears in a document. | |
| 158 | */ | |
| 159 | protected static final class StatEntry { | |
| 160 | private final StringProperty mWord; | |
| 161 | private final IntegerProperty mTally; | |
| 162 | ||
| 163 | public StatEntry( final String word, final int tally ) { | |
| 164 | mWord = new SimpleStringProperty( word ); | |
| 165 | mTally = new SimpleIntegerProperty( tally ); | |
| 166 | } | |
| 167 | ||
| 168 | private StringProperty wordProperty() { | |
| 169 | return mWord; | |
| 170 | } | |
| 171 | ||
| 172 | private IntegerProperty tallyProperty() { | |
| 173 | return mTally; | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
| 1 | 177 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | 2 | package com.keenwrite.ui.listeners; |
| 2 | 3 | |
| 3 | 4 | import com.keenwrite.Caret; |
| 4 | 5 | import com.keenwrite.editors.TextEditor; |
| 6 | import com.keenwrite.events.WordCountEvent; | |
| 5 | 7 | import javafx.beans.property.ReadOnlyObjectProperty; |
| 6 | 8 | import javafx.beans.value.ChangeListener; |
| 7 | 9 | import javafx.beans.value.ObservableValue; |
| 8 | 10 | import javafx.scene.control.Label; |
| 9 | 11 | import javafx.scene.layout.VBox; |
| 12 | import org.greenrobot.eventbus.Subscribe; | |
| 10 | 13 | |
| 14 | import static com.keenwrite.events.Bus.register; | |
| 15 | import static javafx.application.Platform.runLater; | |
| 11 | 16 | import static javafx.geometry.Pos.BASELINE_CENTER; |
| 12 | 17 | |
| ... | ||
| 23 | 28 | private final Label mLineNumberText = new Label(); |
| 24 | 29 | private volatile Caret mCaret; |
| 30 | ||
| 31 | /** | |
| 32 | * Approximate number of words in the document. | |
| 33 | */ | |
| 34 | private volatile int mCount; | |
| 25 | 35 | |
| 26 | 36 | public CaretListener( final ReadOnlyObjectProperty<TextEditor> editor ) { |
| ... | ||
| 37 | 47 | |
| 38 | 48 | updateListener( editor.get().getCaret() ); |
| 49 | register( this ); | |
| 39 | 50 | } |
| 40 | 51 | |
| ... | ||
| 48 | 59 | @Override |
| 49 | 60 | public void changed( |
| 50 | final ObservableValue<? extends Integer> c, | |
| 51 | final Integer o, final Integer n ) { | |
| 61 | final ObservableValue<? extends Integer> c, | |
| 62 | final Integer o, final Integer n ) { | |
| 63 | updateLineNumber(); | |
| 64 | } | |
| 65 | ||
| 66 | @Subscribe | |
| 67 | public void handle( final WordCountEvent event ) { | |
| 68 | mCount = event.getCount(); | |
| 52 | 69 | updateLineNumber(); |
| 53 | 70 | } |
| ... | ||
| 65 | 82 | |
| 66 | 83 | private void updateLineNumber() { |
| 67 | mLineNumberText.setText( mCaret.toString() ); | |
| 84 | runLater( | |
| 85 | () -> mLineNumberText.setText( mCaret.toString() + " | " + mCount ) | |
| 86 | ); | |
| 68 | 87 | } |
| 69 | 88 | } |
| 17 | 17 | |
| 18 | 18 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; |
| 19 | import static com.keenwrite.Constants.ACTION_PREFIX; | |
| 19 | 20 | import static com.keenwrite.Constants.ICON_DIALOG; |
| 20 | 21 | import static com.keenwrite.Messages.get; |
| ... | ||
| 49 | 50 | private static final int CACHE_SIZE = 150; |
| 50 | 51 | |
| 51 | private final ObservableList<LogEntry> mEntries = observableArrayList(); | |
| 52 | private final TableView<LogEntry> mTable = new TableView<>( mEntries ); | |
| 52 | private final ObservableList<LogEntry> mItems = observableArrayList(); | |
| 53 | private final TableView<LogEntry> mTable = new TableView<>( mItems ); | |
| 53 | 54 | |
| 54 | 55 | public LogView() { |
| 55 | 56 | super( INFORMATION ); |
| 56 | setTitle( get( "App.action.view.issues.text" ) ); | |
| 57 | setTitle( get( ACTION_PREFIX + "view.issues.text" ) ); | |
| 57 | 58 | initModality( NONE ); |
| 58 | 59 | initTableView(); |
| ... | ||
| 66 | 67 | @Subscribe |
| 67 | 68 | public void log( final StatusEvent event ) { |
| 68 | runLater( () ->{ | |
| 69 | runLater( () -> { | |
| 69 | 70 | final var logEntry = new LogEntry( event ); |
| 70 | 71 | |
| 71 | if( !mEntries.contains( logEntry ) ) { | |
| 72 | mEntries.add( logEntry ); | |
| 72 | if( !mItems.contains( logEntry ) ) { | |
| 73 | mItems.add( logEntry ); | |
| 73 | 74 | |
| 74 | while( mEntries.size() > CACHE_SIZE ) { | |
| 75 | mEntries.remove( 0 ); | |
| 75 | while( mItems.size() > CACHE_SIZE ) { | |
| 76 | mItems.remove( 0 ); | |
| 76 | 77 | } |
| 77 | 78 | |
| 78 | 79 | mTable.scrollTo( logEntry ); |
| 79 | 80 | } |
| 80 | }); | |
| 81 | } ); | |
| 81 | 82 | } |
| 82 | 83 | |
| ... | ||
| 93 | 94 | */ |
| 94 | 95 | public void clear() { |
| 95 | mEntries.clear(); | |
| 96 | mItems.clear(); | |
| 96 | 97 | clue(); |
| 97 | 98 | } |
| 1 | package com.keenwrite.ui.outline; | |
| 2 | ||
| 3 | import com.keenwrite.events.Bus; | |
| 4 | import com.keenwrite.events.ParseHeadingEvent; | |
| 5 | import javafx.scene.control.TreeCell; | |
| 6 | import javafx.scene.control.TreeItem; | |
| 7 | import javafx.scene.control.TreeView; | |
| 8 | import javafx.scene.text.Text; | |
| 9 | import javafx.util.Callback; | |
| 10 | import org.greenrobot.eventbus.Subscribe; | |
| 11 | ||
| 12 | import static com.keenwrite.Constants.ICON_SIZE_DEFAULT; | |
| 13 | import static com.keenwrite.events.Bus.register; | |
| 14 | import static com.keenwrite.events.CaretNavigationEvent.fireCaretNavigationEvent; | |
| 15 | import static de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon.valueOf; | |
| 16 | import static de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory.get; | |
| 17 | import static javafx.application.Platform.runLater; | |
| 18 | import static javafx.scene.input.MouseButton.PRIMARY; | |
| 19 | import static javafx.scene.input.MouseEvent.MOUSE_PRESSED; | |
| 20 | ||
| 21 | public class DocumentOutline extends TreeView<ParseHeadingEvent> { | |
| 22 | private TreeItem<ParseHeadingEvent> mCurrent; | |
| 23 | ||
| 24 | /** | |
| 25 | * Registers with the {@link Bus}. | |
| 26 | */ | |
| 27 | public DocumentOutline() { | |
| 28 | register( this ); | |
| 29 | ||
| 30 | // Override double-click to issue a caret navigation event. | |
| 31 | setCellFactory( new Callback<>() { | |
| 32 | @Override | |
| 33 | public TreeCell<ParseHeadingEvent> call( | |
| 34 | TreeView<ParseHeadingEvent> treeView ) { | |
| 35 | TreeCell<ParseHeadingEvent> cell = new TreeCell<>() { | |
| 36 | @Override | |
| 37 | protected void updateItem( ParseHeadingEvent item, boolean empty ) { | |
| 38 | super.updateItem( item, empty ); | |
| 39 | if( empty || item == null ) { | |
| 40 | setText( null ); | |
| 41 | setGraphic( null ); | |
| 42 | } | |
| 43 | else { | |
| 44 | setText( item.toString() ); | |
| 45 | setGraphic( createIcon() ); | |
| 46 | } | |
| 47 | } | |
| 48 | }; | |
| 49 | ||
| 50 | cell.addEventFilter( MOUSE_PRESSED, event -> { | |
| 51 | if( event.getButton() == PRIMARY && event.getClickCount() % 2 == 0 ) { | |
| 52 | fireCaretNavigationEvent( cell.getItem().getOffset() ); | |
| 53 | event.consume(); | |
| 54 | } | |
| 55 | } ); | |
| 56 | ||
| 57 | return cell; | |
| 58 | } | |
| 59 | } ); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Updates the {@link TreeView} with the given event data. This method will | |
| 64 | * track the most recently added {@link TreeItem} so that the nesting | |
| 65 | * hierarchy reflects the document hierarchy. | |
| 66 | * | |
| 67 | * @param event Represents a document heading to add to the tree. | |
| 68 | */ | |
| 69 | @Subscribe | |
| 70 | public void handle( final ParseHeadingEvent event ) { | |
| 71 | runLater( | |
| 72 | () -> mCurrent = event.isNewOutline() ? clear( event ) : addItem( event ) | |
| 73 | ); | |
| 74 | } | |
| 75 | ||
| 76 | private TreeItem<ParseHeadingEvent> clear( final ParseHeadingEvent event ) { | |
| 77 | final var root = createTreeItem( event ); | |
| 78 | setRoot( root ); | |
| 79 | setShowRoot( false ); | |
| 80 | return root; | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * This method is called once for every heading in the document. The event | |
| 85 | * data directly corresponds to the sequence of headings in the document. | |
| 86 | * The given event data contains a level that is relative to the last | |
| 87 | * item in the tree. | |
| 88 | * | |
| 89 | * @param next Contains a level value to indicate heading depth. | |
| 90 | */ | |
| 91 | private TreeItem<ParseHeadingEvent> addItem( final ParseHeadingEvent next ) { | |
| 92 | var parent = mCurrent; | |
| 93 | final var item = createTreeItem( next ); | |
| 94 | final var curr = parent.getValue(); | |
| 95 | final var currLevel = curr.getLevel(); | |
| 96 | final var nextLevel = next.getLevel(); | |
| 97 | var deltaLevel = currLevel - nextLevel + 1; | |
| 98 | ||
| 99 | while( deltaLevel > 0 && parent != null ) { | |
| 100 | parent = parent.getParent(); | |
| 101 | deltaLevel--; | |
| 102 | } | |
| 103 | ||
| 104 | if( parent == null ) { | |
| 105 | parent = getRoot(); | |
| 106 | } | |
| 107 | ||
| 108 | parent.getChildren().add( item ); | |
| 109 | ||
| 110 | return item; | |
| 111 | } | |
| 112 | ||
| 113 | private TreeItem<ParseHeadingEvent> createTreeItem( | |
| 114 | final ParseHeadingEvent event ) { | |
| 115 | final var item = new TreeItem<>( event, createIcon() ); | |
| 116 | item.setExpanded( true ); | |
| 117 | return item; | |
| 118 | } | |
| 119 | ||
| 120 | private Text createIcon() { | |
| 121 | return get().createIcon( valueOf( "BOOKMARK" ), ICON_SIZE_DEFAULT ); | |
| 122 | } | |
| 123 | } | |
| 1 | 124 |
| 7 | 7 | import javafx.util.StringConverter; |
| 8 | 8 | |
| 9 | //import javafx.collections.ObservableList; | |
| 10 | //import javafx.scene.control.TreeCell; | |
| 11 | //import javafx.scene.control.TreeItem; | |
| 12 | //import javafx.scene.control.TreeView; | |
| 13 | //import javafx.scene.input.ClipboardContent; | |
| 14 | //import javafx.scene.input.DataFormat; | |
| 15 | //import javafx.scene.input.DragEvent; | |
| 16 | //import javafx.scene.input.MouseEvent; | |
| 17 | //import javafx.util.StringConverter; | |
| 18 | //import java.util.Objects; | |
| 19 | //import static javafx.scene.input.TransferMode.MOVE; | |
| 20 | ||
| 21 | 9 | /** |
| 22 | 10 | * Responsible for creating new {@link TreeCell} instances. |
| 11 | * <p> | |
| 12 | * TODO: #22 -- Upon refactoring variable functionality, re-instate drag & drop. | |
| 13 | * </p> | |
| 23 | 14 | * |
| 24 | 15 | * @param <T> The data type stored in the tree. |
| ... | ||
| 36 | 27 | return new AltTreeCell<>( mConverter ); |
| 37 | 28 | } |
| 38 | ||
| 39 | // private static final String STYLE_CLASS_DROP_TARGET = "drop-target"; | |
| 40 | // private static final DataFormat JAVA_FORMAT = | |
| 41 | // new DataFormat( APP_JAVA_OBJECT.toString() ); | |
| 42 | // | |
| 43 | // private TreeItem<String> mDraggedTreeItem; | |
| 44 | // private TreeCell<String> mTargetCell; | |
| 45 | // | |
| 46 | // @Override | |
| 47 | // public TreeCell<String> call( final TreeView<String> treeView ) { | |
| 48 | // final var cell = createTreeCell(); | |
| 49 | // | |
| 50 | // cell.setOnDragDetected( event -> dragDetected( event, cell ) ); | |
| 51 | // cell.setOnDragOver( event -> dragOver( event, cell ) ); | |
| 52 | // cell.setOnDragDropped( event -> dragDropped( event, cell, treeView ) ); | |
| 53 | // cell.setOnDragDone( event -> dragClear() ); | |
| 54 | // | |
| 55 | // return cell; | |
| 56 | // } | |
| 57 | // | |
| 58 | // private TreeCell<String> createTreeCell() { | |
| 59 | // } | |
| 60 | // | |
| 61 | // /** | |
| 62 | // * Drag start. | |
| 63 | // * | |
| 64 | // * @param event The drag start {@link MouseEvent}. | |
| 65 | // * @param treeCell The cell being dragged. | |
| 66 | // */ | |
| 67 | //private void dragDetected( | |
| 68 | // final MouseEvent event, final TreeCell<String> treeCell ) { | |
| 69 | // final var sourceItem = treeCell.getTreeItem(); | |
| 70 | // | |
| 71 | // // Prevent dragging the root item. | |
| 72 | // if( sourceItem != null && sourceItem.getParent() != null ) { | |
| 73 | // final var dragboard = treeCell.startDragAndDrop( MOVE ); | |
| 74 | // final var clipboard = new ClipboardContent(); | |
| 75 | // clipboard.put( JAVA_FORMAT, sourceItem.getValue() ); | |
| 76 | // dragboard.setContent( clipboard ); | |
| 77 | // dragboard.setDragView( treeCell.snapshot( null, null ) ); | |
| 78 | // event.consume(); | |
| 79 | // | |
| 80 | // mDraggedTreeItem = sourceItem; | |
| 81 | // } | |
| 82 | //} | |
| 83 | // | |
| 84 | // /** | |
| 85 | // * Drag over another {@link TreeCell} instance. | |
| 86 | // * | |
| 87 | // * @param event The drag over {@link DragEvent}. | |
| 88 | // * @param treeCell The cell dragged over. | |
| 89 | // * @throws IllegalStateException Drag transfer "move" mode denied. | |
| 90 | // */ | |
| 91 | // private void dragOver( | |
| 92 | // final DragEvent event, final TreeCell<String> treeCell ) { | |
| 93 | // if( event.getDragboard().hasContent( JAVA_FORMAT ) ) { | |
| 94 | // final var thisItem = treeCell.getTreeItem(); | |
| 95 | // | |
| 96 | // if( mDraggedTreeItem == null || | |
| 97 | // thisItem == null || | |
| 98 | // thisItem == mDraggedTreeItem ) { | |
| 99 | // return; | |
| 100 | // } | |
| 101 | // | |
| 102 | // // Ignore dragging over the root item. | |
| 103 | // if( mDraggedTreeItem.getParent() == null ) { | |
| 104 | // dragClear(); | |
| 105 | // return; | |
| 106 | // } | |
| 107 | // | |
| 108 | // event.acceptTransferModes( MOVE ); | |
| 109 | // | |
| 110 | // if( !Objects.equals( mTargetCell, treeCell ) ) { | |
| 111 | // dragClear(); | |
| 112 | // mTargetCell = treeCell; | |
| 113 | // mTargetCell.getStyleClass().add( STYLE_CLASS_DROP_TARGET ); | |
| 114 | // } | |
| 115 | // } | |
| 116 | // } | |
| 117 | // | |
| 118 | // /** | |
| 119 | // * Dragged item is dropped | |
| 120 | // * | |
| 121 | // * @param event The drag dropped {@link DragEvent}. | |
| 122 | // * @param treeCell The cell dropped onto. | |
| 123 | // */ | |
| 124 | // private void dragDropped( final DragEvent event, | |
| 125 | // final TreeCell<String> treeCell, | |
| 126 | // final TreeView<String> treeView ) { | |
| 127 | // if( !event.getDragboard().hasContent( JAVA_FORMAT ) ) { | |
| 128 | // return; | |
| 129 | // } | |
| 130 | // | |
| 131 | // final var sourceItem = mDraggedTreeItem; | |
| 132 | // final var sourceItemParent = mDraggedTreeItem.getParent(); | |
| 133 | // final var targetItem = treeCell.getTreeItem(); | |
| 134 | // final var targetItemParent = targetItem.getParent(); | |
| 135 | // | |
| 136 | // sourceItemParent.getChildren().remove( sourceItem ); | |
| 137 | // | |
| 138 | // final ObservableList<TreeItem<String>> children; | |
| 139 | // final int index; | |
| 140 | // | |
| 141 | // // Dropping onto a parent node makes the source item the first child. | |
| 142 | // if( Objects.equals( sourceItemParent, targetItem ) ) { | |
| 143 | // children = targetItem.getChildren(); | |
| 144 | // index = 0; | |
| 145 | // } | |
| 146 | // else if( targetItemParent != null) { | |
| 147 | // children = targetItemParent.getChildren(); | |
| 148 | // index = children.indexOf( targetItem ) + 1; | |
| 149 | // } | |
| 150 | // else { | |
| 151 | // children = sourceItemParent.getChildren(); | |
| 152 | // index = 0; | |
| 153 | // } | |
| 154 | // | |
| 155 | // children.add( index, sourceItem ); | |
| 156 | // | |
| 157 | // treeView.getSelectionModel().clearSelection(); | |
| 158 | // treeView.getSelectionModel().select( sourceItem ); | |
| 159 | // | |
| 160 | // // TODO: Notify a listener of the old and new tree item position. | |
| 161 | // | |
| 162 | // event.setDropCompleted( true ); | |
| 163 | // } | |
| 164 | // | |
| 165 | // private void dragClear() { | |
| 166 | // final var targetCell = mTargetCell; | |
| 167 | // | |
| 168 | // if( targetCell != null ) { | |
| 169 | // targetCell.getStyleClass().remove( STYLE_CLASS_DROP_TARGET ); | |
| 170 | // } | |
| 171 | // } | |
| 172 | 29 | } |
| 173 | 30 | |
| 9 | 9 | * Responsible for allowing users to edit items in the tree as well as |
| 10 | 10 | * drag and drop. The goal is to be a drop-in replacement for the regular |
| 11 | * JavaFX {@link TreeView} that does not offer editing and moving {@link | |
| 11 | * JavaFX {@link TreeView}, which does not offer editing and moving {@link | |
| 12 | 12 | * TreeItem} instances. |
| 13 | 13 | * |
| 1 | package com.keenwrite.util; | |
| 2 | ||
| 3 | /** | |
| 4 | * The MurmurHash3 algorithm was created by Austin Appleby and placed in the | |
| 5 | * public domain. This Java port was authored by Yonik Seeley and also placed | |
| 6 | * into the public domain. The author hereby disclaims copyright to this | |
| 7 | * source code. | |
| 8 | * <p> | |
| 9 | * This produces exactly the same hash values as the final C++ version and is | |
| 10 | * thus suitable for producing the same hash values across platforms. | |
| 11 | * <p> | |
| 12 | * The 32-bit x86 version of this hash should be the fastest variant for | |
| 13 | * relatively short keys like ids. Using {@link #hash32} is a | |
| 14 | * good choice for longer strings or returning more than 32 hashed bits. | |
| 15 | * <p> | |
| 16 | * The x86 and x64 versions do not produce the same results because | |
| 17 | * algorithms are optimized for their respective platforms. | |
| 18 | * <p> | |
| 19 | * Code clean-up by White Magic Software, Ltd. | |
| 20 | * </p> | |
| 21 | */ | |
| 22 | public final class MurmurHash { | |
| 23 | /** | |
| 24 | * Returns the 32-bit x86-optimized hash of the UTF-8 bytes of the String | |
| 25 | * without actually encoding the string to a temporary buffer. This is over | |
| 26 | * twice as fast as hashing the result of {@link String#getBytes()}. | |
| 27 | */ | |
| 28 | @SuppressWarnings( "unused" ) | |
| 29 | public static int hash32( CharSequence data, int offset, int len, int seed ) { | |
| 30 | final int c1 = 0xcc9e2d51; | |
| 31 | final int c2 = 0x1b873593; | |
| 32 | ||
| 33 | int h1 = seed; | |
| 34 | ||
| 35 | int pos = offset; | |
| 36 | int end = offset + len; | |
| 37 | int k1 = 0; | |
| 38 | int k2; | |
| 39 | int shift = 0; | |
| 40 | int bits; | |
| 41 | int nBytes = 0; // length in UTF8 bytes | |
| 42 | ||
| 43 | while( pos < end ) { | |
| 44 | int code = data.charAt( pos++ ); | |
| 45 | if( code < 0x80 ) { | |
| 46 | k2 = code; | |
| 47 | bits = 8; | |
| 48 | } | |
| 49 | else if( code < 0x800 ) { | |
| 50 | k2 = (0xC0 | (code >> 6)) | |
| 51 | | ((0x80 | (code & 0x3F)) << 8); | |
| 52 | bits = 16; | |
| 53 | } | |
| 54 | else if( code < 0xD800 || code > 0xDFFF || pos >= end ) { | |
| 55 | // we check for pos>=end to encode an unpaired surrogate as 3 bytes. | |
| 56 | k2 = (0xE0 | (code >> 12)) | |
| 57 | | ((0x80 | ((code >> 6) & 0x3F)) << 8) | |
| 58 | | ((0x80 | (code & 0x3F)) << 16); | |
| 59 | bits = 24; | |
| 60 | } | |
| 61 | else { | |
| 62 | // surrogate pair | |
| 63 | // int utf32 = pos < end ? (int) data.charAt(pos++) : 0; | |
| 64 | int utf32 = data.charAt( pos++ ); | |
| 65 | utf32 = ((code - 0xD7C0) << 10) + (utf32 & 0x3FF); | |
| 66 | k2 = (0xff & (0xF0 | (utf32 >> 18))) | |
| 67 | | ((0x80 | ((utf32 >> 12) & 0x3F))) << 8 | |
| 68 | | ((0x80 | ((utf32 >> 6) & 0x3F))) << 16 | |
| 69 | | (0x80 | (utf32 & 0x3F)) << 24; | |
| 70 | bits = 32; | |
| 71 | } | |
| 72 | ||
| 73 | k1 |= k2 << shift; | |
| 74 | ||
| 75 | // int used_bits = 32 - shift; // how many bits of k2 were used in k1. | |
| 76 | // int unused_bits = bits - used_bits; // (bits-(32-shift)) == | |
| 77 | // bits+shift-32 == bits-newshift | |
| 78 | ||
| 79 | shift += bits; | |
| 80 | if( shift >= 32 ) { | |
| 81 | // mix after we have a complete word | |
| 82 | ||
| 83 | k1 *= c1; | |
| 84 | k1 = (k1 << 15) | (k1 >>> 17); // ROTL32(k1,15); | |
| 85 | k1 *= c2; | |
| 86 | ||
| 87 | h1 ^= k1; | |
| 88 | h1 = (h1 << 13) | (h1 >>> 19); // ROTL32(h1,13); | |
| 89 | h1 = h1 * 5 + 0xe6546b64; | |
| 90 | ||
| 91 | shift -= 32; | |
| 92 | // unfortunately, java won't let you shift 32 bits off, so we need to | |
| 93 | // check for 0 | |
| 94 | if( shift != 0 ) { | |
| 95 | k1 = k2 >>> (bits - shift); // bits used == bits - newshift | |
| 96 | } | |
| 97 | else { | |
| 98 | k1 = 0; | |
| 99 | } | |
| 100 | nBytes += 4; | |
| 101 | } | |
| 102 | ||
| 103 | } // inner | |
| 104 | ||
| 105 | // handle tail | |
| 106 | if( shift > 0 ) { | |
| 107 | nBytes += shift >> 3; | |
| 108 | k1 *= c1; | |
| 109 | k1 = (k1 << 15) | (k1 >>> 17); // ROTL32(k1,15); | |
| 110 | k1 *= c2; | |
| 111 | h1 ^= k1; | |
| 112 | } | |
| 113 | ||
| 114 | // finalization | |
| 115 | h1 ^= nBytes; | |
| 116 | ||
| 117 | // fmix(h1); | |
| 118 | h1 ^= h1 >>> 16; | |
| 119 | h1 *= 0x85ebca6b; | |
| 120 | h1 ^= h1 >>> 13; | |
| 121 | h1 *= 0xc2b2ae35; | |
| 122 | h1 ^= h1 >>> 16; | |
| 123 | ||
| 124 | return h1; | |
| 125 | } | |
| 126 | } | |
| 1 | 127 |
| 167 | 167 | |
| 168 | 168 | # ######################################################################## |
| 169 | # Failure messages with respect to YAML files. | |
| 170 | # ######################################################################## | |
| 171 | ||
| 172 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 173 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 174 | yaml.error.missing=Empty variable value for key ''{0}''. | |
| 175 | yaml.error.tree.form=Unassigned variable near ''{0}''. | |
| 176 | ||
| 177 | # ######################################################################## | |
| 178 | # Text Resource | |
| 179 | # ######################################################################## | |
| 180 | ||
| 181 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 182 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 183 | ||
| 184 | # ######################################################################## | |
| 185 | # Text Resources | |
| 186 | # ######################################################################## | |
| 187 | ||
| 188 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 189 | TextResource.saveFailed.title=Save | |
| 190 | ||
| 191 | # ######################################################################## | |
| 192 | # File Open | |
| 193 | # ######################################################################## | |
| 194 | ||
| 195 | Dialog.file.choose.open.title=Open File | |
| 196 | Dialog.file.choose.save.title=Save File | |
| 197 | Dialog.file.choose.export.title=Export File | |
| 198 | ||
| 199 | Dialog.file.choose.filter.title.source=Source Files | |
| 200 | Dialog.file.choose.filter.title.definition=Variable Files | |
| 201 | Dialog.file.choose.filter.title.xml=XML Files | |
| 202 | Dialog.file.choose.filter.title.all=All Files | |
| 203 | ||
| 204 | # ######################################################################## | |
| 205 | # Browse File | |
| 206 | # ######################################################################## | |
| 207 | ||
| 208 | BrowseFileButton.chooser.title=Browse for local file | |
| 209 | BrowseFileButton.chooser.allFilesFilter=All Files | |
| 210 | BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | |
| 211 | ||
| 212 | # ######################################################################## | |
| 213 | # Alert Dialog | |
| 214 | # ######################################################################## | |
| 215 | ||
| 216 | Alert.file.close.title=Close | |
| 217 | Alert.file.close.text=Save changes to {0}? | |
| 218 | ||
| 219 | # ######################################################################## | |
| 220 | # Image Dialog | |
| 221 | # ######################################################################## | |
| 222 | ||
| 223 | Dialog.image.title=Image | |
| 224 | Dialog.image.chooser.imagesFilter=Images | |
| 225 | Dialog.image.previewLabel.text=Markdown Preview\: | |
| 226 | Dialog.image.textLabel.text=Alternate Text\: | |
| 227 | Dialog.image.titleLabel.text=Title (tooltip)\: | |
| 228 | Dialog.image.urlLabel.text=Image URL\: | |
| 229 | ||
| 230 | # ######################################################################## | |
| 231 | # Hyperlink Dialog | |
| 232 | # ######################################################################## | |
| 233 | ||
| 234 | Dialog.link.title=Link | |
| 235 | Dialog.link.previewLabel.text=Markdown Preview\: | |
| 236 | Dialog.link.textLabel.text=Link Text\: | |
| 237 | Dialog.link.titleLabel.text=Title (tooltip)\: | |
| 238 | Dialog.link.urlLabel.text=Link URL\: | |
| 239 | ||
| 240 | # ######################################################################## | |
| 241 | # About Dialog | |
| 242 | # ######################################################################## | |
| 243 | ||
| 244 | Dialog.about.title=About {0} | |
| 245 | Dialog.about.header={0} | |
| 246 | Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | |
| 247 | ||
| 248 | # ######################################################################## | |
| 249 | # Application Actions | |
| 250 | # ######################################################################## | |
| 251 | ||
| 252 | App.action.file.new.description=Create a new file | |
| 253 | App.action.file.new.accelerator=Shortcut+N | |
| 254 | App.action.file.new.icon=FILE_ALT | |
| 255 | App.action.file.new.text=_New | |
| 256 | ||
| 257 | App.action.file.open.description=Open a new file | |
| 258 | App.action.file.open.accelerator=Shortcut+O | |
| 259 | App.action.file.open.text=_Open... | |
| 260 | App.action.file.open.icon=FOLDER_OPEN_ALT | |
| 261 | ||
| 262 | App.action.file.close.description=Close the current document | |
| 263 | App.action.file.close.accelerator=Shortcut+W | |
| 264 | App.action.file.close.text=_Close | |
| 265 | ||
| 266 | App.action.file.close_all.description=Close all open documents | |
| 267 | App.action.file.close_all.accelerator=Ctrl+F4 | |
| 268 | App.action.file.close_all.text=Close All | |
| 269 | ||
| 270 | App.action.file.save.description=Save the document | |
| 271 | App.action.file.save.accelerator=Shortcut+S | |
| 272 | App.action.file.save.text=_Save | |
| 273 | App.action.file.save.icon=FLOPPY_ALT | |
| 274 | ||
| 275 | App.action.file.save_as.description=Rename the current document | |
| 276 | App.action.file.save_as.text=Save _As | |
| 277 | ||
| 278 | App.action.file.save_all.description=Save all open documents | |
| 279 | App.action.file.save_all.accelerator=Shortcut+Shift+S | |
| 280 | App.action.file.save_all.text=Save A_ll | |
| 281 | ||
| 282 | App.action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 283 | App.action.file.export.text=_Export As | |
| 284 | App.action.file.export.html_svg.text=HTML and S_VG | |
| 285 | ||
| 286 | App.action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 287 | App.action.file.export.html_tex.text=HTML and _TeX | |
| 288 | ||
| 289 | App.action.file.export.markdown.description=Export the current document as Markdown | |
| 290 | App.action.file.export.markdown.text=Markdown | |
| 291 | ||
| 292 | App.action.file.exit.description=Quit the application | |
| 293 | App.action.file.exit.text=E_xit | |
| 294 | ||
| 295 | ||
| 296 | App.action.edit.undo.description=Undo the previous edit | |
| 297 | App.action.edit.undo.accelerator=Shortcut+Z | |
| 298 | App.action.edit.undo.text=_Undo | |
| 299 | App.action.edit.undo.icon=UNDO | |
| 300 | ||
| 301 | App.action.edit.redo.description=Redo the previous edit | |
| 302 | App.action.edit.redo.accelerator=Shortcut+Y | |
| 303 | App.action.edit.redo.text=_Redo | |
| 304 | App.action.edit.redo.icon=REPEAT | |
| 305 | ||
| 306 | App.action.edit.cut.description=Delete the selected text or line | |
| 307 | App.action.edit.cut.accelerator=Shortcut+X | |
| 308 | App.action.edit.cut.text=Cu_t | |
| 309 | App.action.edit.cut.icon=CUT | |
| 310 | ||
| 311 | App.action.edit.copy.description=Copy the selected text | |
| 312 | App.action.edit.copy.accelerator=Shortcut+C | |
| 313 | App.action.edit.copy.text=_Copy | |
| 314 | App.action.edit.copy.icon=COPY | |
| 315 | ||
| 316 | App.action.edit.paste.description=Paste from the clipboard | |
| 317 | App.action.edit.paste.accelerator=Shortcut+V | |
| 318 | App.action.edit.paste.text=_Paste | |
| 319 | App.action.edit.paste.icon=PASTE | |
| 320 | ||
| 321 | App.action.edit.select_all.description=Highlight the current document text | |
| 322 | App.action.edit.select_all.accelerator=Shortcut+A | |
| 323 | App.action.edit.select_all.text=Select _All | |
| 324 | ||
| 325 | App.action.edit.find.description=Search for text in the document | |
| 326 | App.action.edit.find.accelerator=Shortcut+F | |
| 327 | App.action.edit.find.text=_Find | |
| 328 | App.action.edit.find.icon=SEARCH | |
| 329 | ||
| 330 | App.action.edit.find_next.description=Find next occurrence | |
| 331 | App.action.edit.find_next.accelerator=F3 | |
| 332 | App.action.edit.find_next.text=Find _Next | |
| 333 | ||
| 334 | App.action.edit.find_prev.description=Find previous occurrence | |
| 335 | App.action.edit.find_prev.accelerator=Shift+F3 | |
| 336 | App.action.edit.find_prev.text=Find _Prev | |
| 337 | ||
| 338 | App.action.edit.preferences.description=Edit user preferences | |
| 339 | App.action.edit.preferences.accelerator=Ctrl+Alt+S | |
| 340 | App.action.edit.preferences.text=_Preferences | |
| 341 | ||
| 342 | ||
| 343 | App.action.format.bold.description=Insert strong text | |
| 344 | App.action.format.bold.accelerator=Shortcut+B | |
| 345 | App.action.format.bold.text=_Bold | |
| 346 | App.action.format.bold.icon=BOLD | |
| 347 | ||
| 348 | App.action.format.italic.description=Insert text emphasis | |
| 349 | App.action.format.italic.accelerator=Shortcut+I | |
| 350 | App.action.format.italic.text=_Italic | |
| 351 | App.action.format.italic.icon=ITALIC | |
| 352 | ||
| 353 | App.action.format.superscript.description=Insert superscript text | |
| 354 | App.action.format.superscript.accelerator=Shortcut+[ | |
| 355 | App.action.format.superscript.text=Su_perscript | |
| 356 | App.action.format.superscript.icon=SUPERSCRIPT | |
| 357 | ||
| 358 | App.action.format.subscript.description=Insert subscript text | |
| 359 | App.action.format.subscript.accelerator=Shortcut+] | |
| 360 | App.action.format.subscript.text=Su_bscript | |
| 361 | App.action.format.subscript.icon=SUBSCRIPT | |
| 362 | ||
| 363 | App.action.format.strikethrough.description=Insert struck text | |
| 364 | App.action.format.strikethrough.accelerator=Shortcut+T | |
| 365 | App.action.format.strikethrough.text=Stri_kethrough | |
| 366 | App.action.format.strikethrough.icon=STRIKETHROUGH | |
| 367 | ||
| 368 | ||
| 369 | App.action.insert.blockquote.description=Insert blockquote | |
| 370 | App.action.insert.blockquote.accelerator=Ctrl+Q | |
| 371 | App.action.insert.blockquote.text=_Blockquote | |
| 372 | App.action.insert.blockquote.icon=QUOTE_LEFT | |
| 373 | ||
| 374 | App.action.insert.code.description=Insert inline code | |
| 375 | App.action.insert.code.accelerator=Shortcut+K | |
| 376 | App.action.insert.code.text=Inline _Code | |
| 377 | App.action.insert.code.icon=CODE | |
| 378 | ||
| 379 | App.action.insert.fenced_code_block.description=Insert code block | |
| 380 | App.action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 381 | App.action.insert.fenced_code_block.text=_Fenced Code Block | |
| 382 | App.action.insert.fenced_code_block.prompt.text=Enter code here | |
| 383 | App.action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 384 | ||
| 385 | App.action.insert.link.description=Insert hyperlink | |
| 386 | App.action.insert.link.accelerator=Shortcut+L | |
| 387 | App.action.insert.link.text=_Link... | |
| 388 | App.action.insert.link.icon=LINK | |
| 389 | ||
| 390 | App.action.insert.image.description=Insert image | |
| 391 | App.action.insert.image.accelerator=Shortcut+G | |
| 392 | App.action.insert.image.text=_Image... | |
| 393 | App.action.insert.image.icon=PICTURE_ALT | |
| 394 | ||
| 395 | App.action.insert.heading.description=Insert heading level | |
| 396 | App.action.insert.heading.accelerator=Shortcut+ | |
| 397 | App.action.insert.heading.icon=HEADER | |
| 398 | ||
| 399 | App.action.insert.heading_1.description=${App.action.insert.heading.description} 1 | |
| 400 | App.action.insert.heading_1.accelerator=${App.action.insert.heading.accelerator}1 | |
| 401 | App.action.insert.heading_1.text=Heading _1 | |
| 402 | App.action.insert.heading_1.icon=${App.action.insert.heading.icon} | |
| 403 | ||
| 404 | App.action.insert.heading_2.description=${App.action.insert.heading.description} 2 | |
| 405 | App.action.insert.heading_2.accelerator=${App.action.insert.heading.accelerator}2 | |
| 406 | App.action.insert.heading_2.text=Heading _2 | |
| 407 | App.action.insert.heading_2.icon=${App.action.insert.heading.icon} | |
| 408 | ||
| 409 | App.action.insert.heading_3.description=${App.action.insert.heading.description} 3 | |
| 410 | App.action.insert.heading_3.accelerator=${App.action.insert.heading.accelerator}3 | |
| 411 | App.action.insert.heading_3.text=Heading _3 | |
| 412 | App.action.insert.heading_3.icon=${App.action.insert.heading.icon} | |
| 413 | ||
| 414 | App.action.insert.unordered_list.description=Insert bulleted list | |
| 415 | App.action.insert.unordered_list.accelerator=Shortcut+U | |
| 416 | App.action.insert.unordered_list.text=_Unordered List | |
| 417 | App.action.insert.unordered_list.icon=LIST_UL | |
| 418 | ||
| 419 | App.action.insert.ordered_list.description=Insert enumerated list | |
| 420 | App.action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 421 | App.action.insert.ordered_list.text=_Ordered List | |
| 422 | App.action.insert.ordered_list.icon=LIST_OL | |
| 423 | ||
| 424 | App.action.insert.horizontal_rule.description=Insert horizontal rule | |
| 425 | App.action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 426 | App.action.insert.horizontal_rule.text=_Horizontal Rule | |
| 427 | App.action.insert.horizontal_rule.icon=LIST_OL | |
| 428 | ||
| 429 | ||
| 430 | App.action.definition.create.description=Create a new variable | |
| 431 | App.action.definition.create.text=_Create | |
| 432 | App.action.definition.create.icon=TREE | |
| 433 | App.action.definition.create.tooltip=Add new item (Insert) | |
| 434 | ||
| 435 | App.action.definition.rename.description=Rename the selected variable | |
| 436 | App.action.definition.rename.text=_Rename | |
| 437 | App.action.definition.rename.icon=EDIT | |
| 438 | App.action.definition.rename.tooltip=Rename selected item (F2) | |
| 439 | ||
| 440 | App.action.definition.delete.description=Delete the selected variables | |
| 441 | App.action.definition.delete.text=De_lete | |
| 442 | App.action.definition.delete.icon=TRASH | |
| 443 | App.action.definition.delete.tooltip=Delete selected items (Delete) | |
| 444 | ||
| 445 | App.action.definition.insert.description=Insert a variable | |
| 446 | App.action.definition.insert.accelerator=Ctrl+Space | |
| 447 | App.action.definition.insert.text=_Insert | |
| 448 | App.action.definition.insert.icon=STAR | |
| 449 | ||
| 450 | ||
| 451 | App.action.view.refresh.description=Clear all caches | |
| 452 | App.action.view.refresh.accelerator=F5 | |
| 453 | App.action.view.refresh.text=Refresh | |
| 454 | ||
| 455 | App.action.view.preview.description=Open document preview | |
| 456 | App.action.view.preview.accelerator=F6 | |
| 457 | App.action.view.preview.text=Preview | |
| 458 | ||
| 459 | App.action.view.outline.description=Open document outline | |
| 460 | App.action.view.outline.accelerator=F7 | |
| 461 | App.action.view.outline.text=Outline | |
| 462 | ||
| 463 | App.action.view.files.description=Open file system browser | |
| 464 | App.action.view.files.accelerator=F8 | |
| 465 | App.action.view.files.text=File system | |
| 466 | ||
| 467 | App.action.view.menubar.description=Toggle menu bar | |
| 468 | App.action.view.menubar.accelerator=Ctrl+F9 | |
| 469 | App.action.view.menubar.text=Menu bar | |
| 470 | ||
| 471 | App.action.view.toolbar.description=Toggle tool bar | |
| 472 | App.action.view.toolbar.accelerator=Ctrl+Shift+F9 | |
| 473 | App.action.view.toolbar.text=Tool bar | |
| 474 | ||
| 475 | App.action.view.statusbar.description=Toggle status bar | |
| 476 | App.action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9 | |
| 477 | App.action.view.statusbar.text=Status bar | |
| 478 | ||
| 479 | App.action.view.issues.description=Open document issues | |
| 480 | App.action.view.issues.accelerator=F12 | |
| 481 | App.action.view.issues.text=Issues | |
| 482 | ||
| 483 | ||
| 484 | App.action.help.about.description=Show help dialog | |
| 485 | App.action.help.about.accelerator=F1 | |
| 486 | App.action.help.about.text=About | |
| 487 | App.action.help.about.icon=INFO | |
| 169 | # Document Outline Pane | |
| 170 | # ######################################################################## | |
| 171 | ||
| 172 | Pane.statistics.title=Statistics | |
| 173 | ||
| 174 | # ######################################################################## | |
| 175 | # Failure messages with respect to YAML files. | |
| 176 | # ######################################################################## | |
| 177 | ||
| 178 | yaml.error.open=Could not open YAML file (ensure non-empty file). | |
| 179 | yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''. | |
| 180 | yaml.error.missing=Empty variable value for key ''{0}''. | |
| 181 | yaml.error.tree.form=Unassigned variable near ''{0}''. | |
| 182 | ||
| 183 | # ######################################################################## | |
| 184 | # Text Resource | |
| 185 | # ######################################################################## | |
| 186 | ||
| 187 | TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist. | |
| 188 | TextResource.load.error.permissions=The file ''{0}'' must be readable and writable. | |
| 189 | ||
| 190 | # ######################################################################## | |
| 191 | # Text Resources | |
| 192 | # ######################################################################## | |
| 193 | ||
| 194 | TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1} | |
| 195 | TextResource.saveFailed.title=Save | |
| 196 | ||
| 197 | # ######################################################################## | |
| 198 | # File Open | |
| 199 | # ######################################################################## | |
| 200 | ||
| 201 | Dialog.file.choose.open.title=Open File | |
| 202 | Dialog.file.choose.save.title=Save File | |
| 203 | Dialog.file.choose.export.title=Export File | |
| 204 | ||
| 205 | Dialog.file.choose.filter.title.source=Source Files | |
| 206 | Dialog.file.choose.filter.title.definition=Variable Files | |
| 207 | Dialog.file.choose.filter.title.xml=XML Files | |
| 208 | Dialog.file.choose.filter.title.all=All Files | |
| 209 | ||
| 210 | # ######################################################################## | |
| 211 | # Browse File | |
| 212 | # ######################################################################## | |
| 213 | ||
| 214 | BrowseFileButton.chooser.title=Browse for local file | |
| 215 | BrowseFileButton.chooser.allFilesFilter=All Files | |
| 216 | BrowseFileButton.tooltip=${BrowseFileButton.chooser.title} | |
| 217 | ||
| 218 | # ######################################################################## | |
| 219 | # Alert Dialog | |
| 220 | # ######################################################################## | |
| 221 | ||
| 222 | Alert.file.close.title=Close | |
| 223 | Alert.file.close.text=Save changes to {0}? | |
| 224 | ||
| 225 | # ######################################################################## | |
| 226 | # Image Dialog | |
| 227 | # ######################################################################## | |
| 228 | ||
| 229 | Dialog.image.title=Image | |
| 230 | Dialog.image.chooser.imagesFilter=Images | |
| 231 | Dialog.image.previewLabel.text=Markdown Preview\: | |
| 232 | Dialog.image.textLabel.text=Alternate Text\: | |
| 233 | Dialog.image.titleLabel.text=Title (tooltip)\: | |
| 234 | Dialog.image.urlLabel.text=Image URL\: | |
| 235 | ||
| 236 | # ######################################################################## | |
| 237 | # Hyperlink Dialog | |
| 238 | # ######################################################################## | |
| 239 | ||
| 240 | Dialog.link.title=Link | |
| 241 | Dialog.link.previewLabel.text=Markdown Preview\: | |
| 242 | Dialog.link.textLabel.text=Link Text\: | |
| 243 | Dialog.link.titleLabel.text=Title (tooltip)\: | |
| 244 | Dialog.link.urlLabel.text=Link URL\: | |
| 245 | ||
| 246 | # ######################################################################## | |
| 247 | # About Dialog | |
| 248 | # ######################################################################## | |
| 249 | ||
| 250 | Dialog.about.title=About {0} | |
| 251 | Dialog.about.header={0} | |
| 252 | Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1} | |
| 253 | ||
| 254 | # ######################################################################## | |
| 255 | # Application Actions | |
| 256 | # ######################################################################## | |
| 257 | ||
| 258 | Action.file.new.description=Create a new file | |
| 259 | Action.file.new.accelerator=Shortcut+N | |
| 260 | Action.file.new.icon=FILE_ALT | |
| 261 | Action.file.new.text=_New | |
| 262 | ||
| 263 | Action.file.open.description=Open a new file | |
| 264 | Action.file.open.accelerator=Shortcut+O | |
| 265 | Action.file.open.text=_Open... | |
| 266 | Action.file.open.icon=FOLDER_OPEN_ALT | |
| 267 | ||
| 268 | Action.file.close.description=Close the current document | |
| 269 | Action.file.close.accelerator=Shortcut+W | |
| 270 | Action.file.close.text=_Close | |
| 271 | ||
| 272 | Action.file.close_all.description=Close all open documents | |
| 273 | Action.file.close_all.accelerator=Ctrl+F4 | |
| 274 | Action.file.close_all.text=Close All | |
| 275 | ||
| 276 | Action.file.save.description=Save the document | |
| 277 | Action.file.save.accelerator=Shortcut+S | |
| 278 | Action.file.save.text=_Save | |
| 279 | Action.file.save.icon=FLOPPY_ALT | |
| 280 | ||
| 281 | Action.file.save_as.description=Rename the current document | |
| 282 | Action.file.save_as.text=Save _As | |
| 283 | ||
| 284 | Action.file.save_all.description=Save all open documents | |
| 285 | Action.file.save_all.accelerator=Shortcut+Shift+S | |
| 286 | Action.file.save_all.text=Save A_ll | |
| 287 | ||
| 288 | Action.file.export.html_svg.description=Export the current document as HTML + SVG | |
| 289 | Action.file.export.text=_Export As | |
| 290 | Action.file.export.html_svg.text=HTML and S_VG | |
| 291 | ||
| 292 | Action.file.export.html_tex.description=Export the current document as HTML + TeX | |
| 293 | Action.file.export.html_tex.text=HTML and _TeX | |
| 294 | ||
| 295 | Action.file.export.markdown.description=Export the current document as Markdown | |
| 296 | Action.file.export.markdown.text=Markdown | |
| 297 | ||
| 298 | Action.file.exit.description=Quit the application | |
| 299 | Action.file.exit.text=E_xit | |
| 300 | ||
| 301 | ||
| 302 | Action.edit.undo.description=Undo the previous edit | |
| 303 | Action.edit.undo.accelerator=Shortcut+Z | |
| 304 | Action.edit.undo.text=_Undo | |
| 305 | Action.edit.undo.icon=UNDO | |
| 306 | ||
| 307 | Action.edit.redo.description=Redo the previous edit | |
| 308 | Action.edit.redo.accelerator=Shortcut+Y | |
| 309 | Action.edit.redo.text=_Redo | |
| 310 | Action.edit.redo.icon=REPEAT | |
| 311 | ||
| 312 | Action.edit.cut.description=Delete the selected text or line | |
| 313 | Action.edit.cut.accelerator=Shortcut+X | |
| 314 | Action.edit.cut.text=Cu_t | |
| 315 | Action.edit.cut.icon=CUT | |
| 316 | ||
| 317 | Action.edit.copy.description=Copy the selected text | |
| 318 | Action.edit.copy.accelerator=Shortcut+C | |
| 319 | Action.edit.copy.text=_Copy | |
| 320 | Action.edit.copy.icon=COPY | |
| 321 | ||
| 322 | Action.edit.paste.description=Paste from the clipboard | |
| 323 | Action.edit.paste.accelerator=Shortcut+V | |
| 324 | Action.edit.paste.text=_Paste | |
| 325 | Action.edit.paste.icon=PASTE | |
| 326 | ||
| 327 | Action.edit.select_all.description=Highlight the current document text | |
| 328 | Action.edit.select_all.accelerator=Shortcut+A | |
| 329 | Action.edit.select_all.text=Select _All | |
| 330 | ||
| 331 | Action.edit.find.description=Search for text in the document | |
| 332 | Action.edit.find.accelerator=Shortcut+F | |
| 333 | Action.edit.find.text=_Find | |
| 334 | Action.edit.find.icon=SEARCH | |
| 335 | ||
| 336 | Action.edit.find_next.description=Find next occurrence | |
| 337 | Action.edit.find_next.accelerator=F3 | |
| 338 | Action.edit.find_next.text=Find _Next | |
| 339 | ||
| 340 | Action.edit.find_prev.description=Find previous occurrence | |
| 341 | Action.edit.find_prev.accelerator=Shift+F3 | |
| 342 | Action.edit.find_prev.text=Find _Prev | |
| 343 | ||
| 344 | Action.edit.preferences.description=Edit user preferences | |
| 345 | Action.edit.preferences.accelerator=Ctrl+Alt+S | |
| 346 | Action.edit.preferences.text=_Preferences | |
| 347 | ||
| 348 | ||
| 349 | Action.format.bold.description=Insert strong text | |
| 350 | Action.format.bold.accelerator=Shortcut+B | |
| 351 | Action.format.bold.text=_Bold | |
| 352 | Action.format.bold.icon=BOLD | |
| 353 | ||
| 354 | Action.format.italic.description=Insert text emphasis | |
| 355 | Action.format.italic.accelerator=Shortcut+I | |
| 356 | Action.format.italic.text=_Italic | |
| 357 | Action.format.italic.icon=ITALIC | |
| 358 | ||
| 359 | Action.format.superscript.description=Insert superscript text | |
| 360 | Action.format.superscript.accelerator=Shortcut+[ | |
| 361 | Action.format.superscript.text=Su_perscript | |
| 362 | Action.format.superscript.icon=SUPERSCRIPT | |
| 363 | ||
| 364 | Action.format.subscript.description=Insert subscript text | |
| 365 | Action.format.subscript.accelerator=Shortcut+] | |
| 366 | Action.format.subscript.text=Su_bscript | |
| 367 | Action.format.subscript.icon=SUBSCRIPT | |
| 368 | ||
| 369 | Action.format.strikethrough.description=Insert struck text | |
| 370 | Action.format.strikethrough.accelerator=Shortcut+T | |
| 371 | Action.format.strikethrough.text=Stri_kethrough | |
| 372 | Action.format.strikethrough.icon=STRIKETHROUGH | |
| 373 | ||
| 374 | ||
| 375 | Action.insert.blockquote.description=Insert blockquote | |
| 376 | Action.insert.blockquote.accelerator=Ctrl+Q | |
| 377 | Action.insert.blockquote.text=_Blockquote | |
| 378 | Action.insert.blockquote.icon=QUOTE_LEFT | |
| 379 | ||
| 380 | Action.insert.code.description=Insert inline code | |
| 381 | Action.insert.code.accelerator=Shortcut+K | |
| 382 | Action.insert.code.text=Inline _Code | |
| 383 | Action.insert.code.icon=CODE | |
| 384 | ||
| 385 | Action.insert.fenced_code_block.description=Insert code block | |
| 386 | Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K | |
| 387 | Action.insert.fenced_code_block.text=_Fenced Code Block | |
| 388 | Action.insert.fenced_code_block.prompt.text=Enter code here | |
| 389 | Action.insert.fenced_code_block.icon=FILE_CODE_ALT | |
| 390 | ||
| 391 | Action.insert.link.description=Insert hyperlink | |
| 392 | Action.insert.link.accelerator=Shortcut+L | |
| 393 | Action.insert.link.text=_Link... | |
| 394 | Action.insert.link.icon=LINK | |
| 395 | ||
| 396 | Action.insert.image.description=Insert image | |
| 397 | Action.insert.image.accelerator=Shortcut+G | |
| 398 | Action.insert.image.text=_Image... | |
| 399 | Action.insert.image.icon=PICTURE_ALT | |
| 400 | ||
| 401 | Action.insert.heading.description=Insert heading level | |
| 402 | Action.insert.heading.accelerator=Shortcut+ | |
| 403 | Action.insert.heading.icon=HEADER | |
| 404 | ||
| 405 | Action.insert.heading_1.description=${Action.insert.heading.description} 1 | |
| 406 | Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1 | |
| 407 | Action.insert.heading_1.text=Heading _1 | |
| 408 | Action.insert.heading_1.icon=${Action.insert.heading.icon} | |
| 409 | ||
| 410 | Action.insert.heading_2.description=${Action.insert.heading.description} 2 | |
| 411 | Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2 | |
| 412 | Action.insert.heading_2.text=Heading _2 | |
| 413 | Action.insert.heading_2.icon=${Action.insert.heading.icon} | |
| 414 | ||
| 415 | Action.insert.heading_3.description=${Action.insert.heading.description} 3 | |
| 416 | Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3 | |
| 417 | Action.insert.heading_3.text=Heading _3 | |
| 418 | Action.insert.heading_3.icon=${Action.insert.heading.icon} | |
| 419 | ||
| 420 | Action.insert.unordered_list.description=Insert bulleted list | |
| 421 | Action.insert.unordered_list.accelerator=Shortcut+U | |
| 422 | Action.insert.unordered_list.text=_Unordered List | |
| 423 | Action.insert.unordered_list.icon=LIST_UL | |
| 424 | ||
| 425 | Action.insert.ordered_list.description=Insert enumerated list | |
| 426 | Action.insert.ordered_list.accelerator=Shortcut+Shift+O | |
| 427 | Action.insert.ordered_list.text=_Ordered List | |
| 428 | Action.insert.ordered_list.icon=LIST_OL | |
| 429 | ||
| 430 | Action.insert.horizontal_rule.description=Insert horizontal rule | |
| 431 | Action.insert.horizontal_rule.accelerator=Shortcut+H | |
| 432 | Action.insert.horizontal_rule.text=_Horizontal Rule | |
| 433 | Action.insert.horizontal_rule.icon=LIST_OL | |
| 434 | ||
| 435 | ||
| 436 | Action.definition.create.description=Create a new variable | |
| 437 | Action.definition.create.text=_Create | |
| 438 | Action.definition.create.icon=TREE | |
| 439 | Action.definition.create.tooltip=Add new item (Insert) | |
| 440 | ||
| 441 | Action.definition.rename.description=Rename the selected variable | |
| 442 | Action.definition.rename.text=_Rename | |
| 443 | Action.definition.rename.icon=EDIT | |
| 444 | Action.definition.rename.tooltip=Rename selected item (F2) | |
| 445 | ||
| 446 | Action.definition.delete.description=Delete the selected variables | |
| 447 | Action.definition.delete.text=De_lete | |
| 448 | Action.definition.delete.icon=TRASH | |
| 449 | Action.definition.delete.tooltip=Delete selected items (Delete) | |
| 450 | ||
| 451 | Action.definition.insert.description=Insert a variable | |
| 452 | Action.definition.insert.accelerator=Ctrl+Space | |
| 453 | Action.definition.insert.text=_Insert | |
| 454 | Action.definition.insert.icon=STAR | |
| 455 | ||
| 456 | ||
| 457 | Action.view.refresh.description=Clear all caches | |
| 458 | Action.view.refresh.accelerator=F5 | |
| 459 | Action.view.refresh.text=Refresh | |
| 460 | ||
| 461 | Action.view.preview.description=Open document preview | |
| 462 | Action.view.preview.accelerator=F6 | |
| 463 | Action.view.preview.text=Preview | |
| 464 | ||
| 465 | Action.view.outline.description=Open document outline | |
| 466 | Action.view.outline.accelerator=F7 | |
| 467 | Action.view.outline.text=Outline | |
| 468 | ||
| 469 | Action.view.statistics.description=Open document word counts | |
| 470 | Action.view.statistics.accelerator=F8 | |
| 471 | Action.view.statistics.text=Statistics | |
| 472 | ||
| 473 | ||
| 474 | Action.view.files.description=Open file system browser | |
| 475 | Action.view.files.accelerator=F8 | |
| 476 | Action.view.files.text=File system | |
| 477 | ||
| 478 | Action.view.menubar.description=Toggle menu bar | |
| 479 | Action.view.menubar.accelerator=Ctrl+F9 | |
| 480 | Action.view.menubar.text=Menu bar | |
| 481 | ||
| 482 | Action.view.toolbar.description=Toggle tool bar | |
| 483 | Action.view.toolbar.accelerator=Ctrl+Shift+F9 | |
| 484 | Action.view.toolbar.text=Tool bar | |
| 485 | ||
| 486 | Action.view.statusbar.description=Toggle status bar | |
| 487 | Action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9 | |
| 488 | Action.view.statusbar.text=Status bar | |
| 489 | ||
| 490 | Action.view.issues.description=Open document issues | |
| 491 | Action.view.issues.accelerator=F12 | |
| 492 | Action.view.issues.text=Issues | |
| 493 | ||
| 494 | ||
| 495 | Action.help.about.description=Show help dialog | |
| 496 | Action.help.about.accelerator=F1 | |
| 497 | Action.help.about.text=About | |
| 498 | Action.help.about.icon=INFO | |
| 488 | 499 |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | /* Avoid clipping text descenders in statistics table row. */ | |
| 85 | .table-row-cell { | |
| 86 | -fx-cell-size: 30px; | |
| 87 | } | |
| 88 |