diff --git a/build.gradle b/build.gradle index c5faed3fa0f..f19e6d49b38 100644 --- a/build.gradle +++ b/build.gradle @@ -2752,6 +2752,181 @@ project(":controls") { addValidateSourceSets(project, sourceSets) } +project(":incubator.input") { + project.ext.buildModule = true + project.ext.includeSources = true + project.ext.moduleRuntime = true + project.ext.moduleName = "jfx.incubator.input" + project.ext.incubating = true + + sourceSets { + main + shims { + java { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } + } + test { + java { + compileClasspath += sourceSets.shims.output + runtimeClasspath += sourceSets.shims.output + } + } + } + + project.ext.moduleSourcePath = defaultModuleSourcePath + project.ext.moduleSourcePathShim = defaultModuleSourcePathShim + + commonModuleSetup(project, [ + 'base', + 'graphics', + 'controls', + 'incubator.input' + ]) + + dependencies { + testImplementation project(":base").sourceSets.test.output + testImplementation project(":graphics").sourceSets.test.output + testImplementation project(":controls").sourceSets.test.output + implementation project(':base') + implementation project(':graphics') + implementation project(':controls') + } + + test { + jvmArgs "-Djavafx.toolkit=test.com.sun.javafx.pgstub.StubToolkit" + } + + def modulePath = "${project.sourceSets.main.java.getDestinationDirectory().get().getAsFile()}" + modulePath += File.pathSeparator + "${rootProject.projectDir}/modules/javafx.controls/build/classes/java/main" + modulePath += File.pathSeparator + "${rootProject.projectDir}/modules/javafx.graphics/build/classes/java/main" + modulePath += File.pathSeparator + "${rootProject.projectDir}/modules/javafx.base/build/classes/java/main" + + // FIXME: KCR +// processResources { +// doLast { +// def cssFiles = fileTree(dir: "$moduleDir/com/sun/javafx/scene/control/skin") +// cssFiles.include "**/*.css" +// cssFiles.each { css -> +// logger.info("converting CSS to BSS ${css}"); +// +// javaexec { +// executable = JAVA +// workingDir = project.projectDir +// jvmArgs += patchModuleArgs +// jvmArgs += "--module-path=$modulePath" +// jvmArgs += "--add-modules=javafx.graphics" +// mainClass = "com.sun.javafx.css.parser.Css2Bin" +// args css +// } +// } +// } +// } +// +// def copyShimBssTask = project.task("copyShimBss", type: Copy, +// dependsOn: [project.tasks.getByName("compileJava"), +// project.tasks.getByName("processResources")]) { +// from project.moduleDir +// into project.moduleShimsDir +// include "**/*.bss" +// } +// processShimsResources.dependsOn(copyShimBssTask) + + addMavenPublication(project, [ 'graphics' , 'controls']) + + addValidateSourceSets(project, sourceSets) +} + +project(":incubator.richtext") { + project.ext.buildModule = true + project.ext.includeSources = true + project.ext.moduleRuntime = true + project.ext.moduleName = "jfx.incubator.richtext" + project.ext.incubating = true + + sourceSets { + main + shims { + java { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } + } + test { + java { + compileClasspath += sourceSets.shims.output + runtimeClasspath += sourceSets.shims.output + } + } + } + + project.ext.moduleSourcePath = defaultModuleSourcePath + project.ext.moduleSourcePathShim = defaultModuleSourcePathShim + + commonModuleSetup(project, [ + 'base', + 'graphics', + 'controls', + 'incubator.input', + 'incubator.richtext' + ]) + + dependencies { + testImplementation project(":base").sourceSets.test.output + testImplementation project(":graphics").sourceSets.test.output + testImplementation project(":controls").sourceSets.test.output + testImplementation project(":incubator.input").sourceSets.test.output + implementation project(':base') + implementation project(':graphics') + implementation project(':controls') + implementation project(':incubator.input') + } + + test { + jvmArgs "-Djavafx.toolkit=test.com.sun.javafx.pgstub.StubToolkit" + } + + def modulePath = "${project.sourceSets.main.java.getDestinationDirectory().get().getAsFile()}" + modulePath += File.pathSeparator + "${rootProject.projectDir}/modules/javafx.controls/build/classes/java/main" + modulePath += File.pathSeparator + "${rootProject.projectDir}/modules/javafx.graphics/build/classes/java/main" + modulePath += File.pathSeparator + "${rootProject.projectDir}/modules/javafx.base/build/classes/java/main" + + // FIXME: KCR +// processResources { +// doLast { +// def cssFiles = fileTree(dir: "$moduleDir/com/sun/javafx/scene/control/skin") +// cssFiles.include "**/*.css" +// cssFiles.each { css -> +// logger.info("converting CSS to BSS ${css}"); +// +// javaexec { +// executable = JAVA +// workingDir = project.projectDir +// jvmArgs += patchModuleArgs +// jvmArgs += "--module-path=$modulePath" +// jvmArgs += "--add-modules=javafx.graphics" +// mainClass = "com.sun.javafx.css.parser.Css2Bin" +// args css +// } +// } +// } +// } +// +// def copyShimBssTask = project.task("copyShimBss", type: Copy, +// dependsOn: [project.tasks.getByName("compileJava"), +// project.tasks.getByName("processResources")]) { +// from project.moduleDir +// into project.moduleShimsDir +// include "**/*.bss" +// } +// processShimsResources.dependsOn(copyShimBssTask) + + addMavenPublication(project, [ 'graphics' , 'controls']) + + addValidateSourceSets(project, sourceSets) +} + project(":swing") { // We need to skip setting compiler.options.release for this module, @@ -3863,7 +4038,17 @@ project(":systemTests") { testImplementation project(":swing").sourceSets.test.output } - def dependentProjects = [ 'base', 'graphics', 'controls', 'media', 'web', 'swing', 'fxml' ] + def dependentProjects = [ + 'base', + 'graphics', + 'controls', + 'incubator.input', + 'incubator.richtext', + 'media', + 'web', + 'swing', + 'fxml' + ] commonModuleSetup(project, dependentProjects) File testJavaPolicyFile = new File(rootProject.buildDir, TESTJAVAPOLICYFILE); @@ -4243,7 +4428,10 @@ task javadoc(type: Javadoc, dependsOn: createMSPfile) { description = "Generates the JavaDoc for all the public API" executable = JAVADOC def projectsToDocument = [ - project(":base"), project(":graphics"), project(":controls"), project(":media"), + project(":base"), project(":graphics"), project(":controls"), + project(":incubator.input"), + project(":incubator.richtext"), + project(":media"), project(":swing"), /*project(":swt"),*/ project(":fxml"), project(":web")] source(projectsToDocument.collect({ [it.sourceSets.main.java] @@ -5567,6 +5755,7 @@ compileTargets { t -> def modnames = [] moduleProjList.each { project -> if (project.hasProperty("moduleName") && project.buildModule) { + def incubating = project.hasProperty("incubating") && project.ext.incubating modnames << project.ext.moduleName File dir; if (project.sourceSets.hasProperty('shims')) { @@ -5578,16 +5767,18 @@ compileTargets { t -> def dstModuleDir = cygpath(dir.path) modpath << "${dstModuleDir}" - String themod = dir.toURI() - testJavaPolicyFile << "grant codeBase \"${themod}\" {\n" + - " permission java.security.AllPermission;\n" + - "};\n" - - dir = new File(rootProject.buildDir, "sdk/lib/${project.ext.moduleName}.jar") - themod = dir.toURI() - runJavaPolicyFile << "grant codeBase \"${themod}\" {\n" + - " permission java.security.AllPermission;\n" + - "};\n" + if (!incubating) { + String themod = dir.toURI() + testJavaPolicyFile << "grant codeBase \"${themod}\" {\n" + + " permission java.security.AllPermission;\n" + + "};\n" + + dir = new File(rootProject.buildDir, "sdk/lib/${project.ext.moduleName}.jar") + themod = dir.toURI() + runJavaPolicyFile << "grant codeBase \"${themod}\" {\n" + + " permission java.security.AllPermission;\n" + + "};\n" + } } } @@ -5633,6 +5824,8 @@ compileTargets { t -> def jmodName = "${moduleName}.jmod" def jmodFile = "${jmodsDir}/${jmodName}" + def incubating = project.hasProperty("incubating") && project.ext.incubating + // On Windows, copy the native libraries in the jmod image // to a "javafx" subdir to avoid conflicting with the Microsoft // DLLs that are shipped with the JDK @@ -5674,6 +5867,10 @@ compileTargets { t -> if (sourceDateEpoch != null) { args("--date", extendedTimestamp) } + if (incubating) { + args("--do-not-resolve-by-default") + args("--warn-if-resolved=incubating") + } args(jmodFile) } } diff --git a/doc-files/behavior/RichTextAreaBehavior.md b/doc-files/behavior/RichTextAreaBehavior.md new file mode 100644 index 00000000000..fd57b31e971 --- /dev/null +++ b/doc-files/behavior/RichTextAreaBehavior.md @@ -0,0 +1,164 @@ +# RichTextArea Behavior + +## Function Tags + +|Function Tag |Description | +|:-------------------------|:----------------------------------------------------------------------------| +|BACKSPACE |Deletes the symbol before the caret +|COPY |Copies selected text to the clipboard +|CUT |Cuts selected text and places it to the clipboard +|DELETE |Deletes the symbol at the caret +|DELETE_PARAGRAPH |Deletes paragraph at the caret, or selected paragraphs +|DELETE_PARAGRAPH_START |Deletes text from the caret to paragraph start, ignoring selection +|DELETE_WORD_NEXT_END |Deletes empty paragraph or text to the end of the next word +|DELETE_WORD_NEXT_START |Deletes empty paragraph or text to the start of the next word +|DELETE_WORD_PREVIOUS |Deletes (multiple) empty paragraphs or text to the beginning of the previous word +|DESELECT |Clears any existing selection by moving anchor to the caret position +|FOCUS_NEXT |Transfer focus to the next focusable node +|FOCUS_PREVIOUS |Transfer focus to the previous focusable node +|INSERT_LINE_BREAK |Inserts a line break at the caret +|INSERT_TAB |Inserts a tab symbol at the caret (editable), or transfer focus to the next focusable node +|MOVE_DOWN |Moves the caret one visual line down +|MOVE_LEFT |Moves the caret one symbol to the left +|MOVE_PARAGRAPH_DOWN |Moves the caret to the end of the current paragraph, or, if already there, to the end of the next paragraph +|MOVE_PARAGRAPH_UP |Moves the caret to the start of the current paragraph, or, if already there, to the start of the previous paragraph +|MOVE_RIGHT |Moves the caret one symbol to the right +|MOVE_TO_DOCUMENT_END |Moves the caret to after the last character of the text +|MOVE_TO_DOCUMENT_START |Moves the caret to before the first character of the text +|MOVE_TO_PARAGRAPH_END |Moves the caret to the end of the paragraph at caret +|MOVE_TO_PARAGRAPH_START |Moves the caret to the beginning of the paragraph at caret +|MOVE_UP |Moves the caret one visual text line up +|MOVE_WORD_LEFT |Moves the caret one word left (previous word if LTR, next word if RTL) +|MOVE_WORD_NEXT_END |Moves the caret to the end of the next word +|MOVE_WORD_NEXT_START |Moves the caret to the start of the next word, or next paragraph if at the start of an empty paragraph +|MOVE_WORD_PREVIOUS |Moves the caret to the beginning of previous word +|MOVE_WORD_RIGHT |Moves the caret one word right (next word if LTR, previous word if RTL) +|PAGE_DOWN |Moves the caret one visual page down +|PAGE_UP |Moves the caret one visual page up +|PASTE |Pastes the clipboard content +|PASTE_PLAIN_TEXT |Pastes the plain text clipboard content +|REDO |If possible, redoes the last undone modification +|SELECT_ALL |Selects all text in the document +|SELECT_DOWN |Extends selection one visual text line down +|SELECT_LEFT |Extends selection one symbol to the left +|SELECT_PAGE_DOWN |Extends selection one visible page down +|SELECT_PAGE_UP |Extends selection one visible page up +|SELECT_PARAGRAPH |Selects the current paragraph +|SELECT_PARAGRAPH_DOWN |Extends selection to the end of the current paragraph, or, if already there, to the end of the next paragraph +|SELECT_PARAGRAPH_END |Extends selection to the paragraph end +|SELECT_PARAGRAPH_START |Extends selection to the paragraph start +|SELECT_PARAGRAPH_UP |Extends selection to the start of the current paragraph, or, if already there, to the start of the previous paragraph +|SELECT_RIGHT |Extends selection one symbol to the right +|SELECT_TO_DOCUMENT_END |Extends selection to the end of the document +|SELECT_TO_DOCUMENT_START |Extends selection to the start of the document +|SELECT_UP |Extends selection one visual text line up +|SELECT_WORD |Selects a word at the caret position +|SELECT_WORD_LEFT |Extends selection to the previous word (LTR) or next word (RTL) +|SELECT_WORD_NEXT |Extends selection to the beginning of next word +|SELECT_WORD_NEXT_END |Extends selection to the end of next word +|SELECT_WORD_PREVIOUS |Extends selection to the previous word +|SELECT_WORD_RIGHT |Extends selection to the next word (LTR) or previous word (RTL) +|UNDO |If possible, undoes the last modification + + + +## Key Bindings + +|Key Combination |Platform |Tag |Notes | +|:---------------------|:----------|:-------------------------|:-----------------| +|shortcut-A | |SELECT_ALL | +|ctrl-BACK_SLASH |linux, win |DESELECT | +|BACKSPACE | |BACKSPACE |7 +|ctrl-BACKSPACE |linux, win |DELETE_WORD_NEXT_START | +|option-BACKSPACE |mac |DELETE_WORD_NEXT_START |7 +|shift-BACKSPACE | |BACKSPACE |7 +|shortcut-BACKSPACE |mac |DELETE_PARAGRAPH_START |7, mac only +|shortcut-C | |COPY | +|COPY | |COPY | +|CUT | |CUT | +|shortcut-D | |DELETE_PARAGRAPH | +|DELETE | |DELETE |8 +|option-DELETE |mac |DELETE_WORD_NEXT_END |8, option-fn-delete +|ctrl-DELETE |linux, win |DELETE_WORD_NEXT_START | +|DOWN | |MOVE_DOWN | +|ctrl-DOWN |linux, win |MOVE_PARAGRAPH_DOWN | +|ctrl-shift-DOWN |linux, win |SELECT_PARAGRAPH_DOWN | +|option-DOWN |mac |MOVE_PARAGRAPH_DOWN | +|option-shift-DOWN |mac |SELECT_PARAGRAPH_DOWN | +|shift-DOWN | |SELECT_DOWN | +|shift-shortcut-DOWN |mac |SELECT_TO_DOCUMENT_END | +|shortcut-DOWN |mac |MOVE_TO_DOCUMENT_END | +|END | |MOVE_TO_PARAGRAPH_END |4 +|ctrl-END |linux, win |MOVE_TO_DOCUMENT_END | +|ctrl-shift-END |linux, win |SELECT_TO_DOCUMENT_END | +|shift-END |linux, win |SELECT_PARAGRAPH_END | +|ENTER | |INSERT_LINE_BREAK | +|ctrl-H |linux, win |BACKSPACE | +|HOME | |MOVE_TO_PARAGRAPH_START |3 +|ctrl-HOME |linux, win |MOVE_TO_DOCUMENT_START | +|ctrl-shift-HOME |linux, win |SELECT_TO_DOCUMENT_START | +|shift-HOME |linux, win |SELECT_PARAGRAPH_START | +|LEFT | |MOVE_LEFT | +|ctrl-LEFT |linux, win |MOVE_WORD_LEFT | +|ctrl-shift-LEFT |linux, win |SELECT_WORD_LEFT | +|option-LEFT |mac |MOVE_WORD_LEFT | +|option-shift-LEFT |mac |SELECT_WORD_LEFT | +|shift-LEFT | |SELECT_LEFT | +|shift-shortcut-LEFT |mac |SELECT_PARAGRAPH_START | +|shortcut-LEFT |mac |MOVE_TO_PARAGRAPH_START | +|PAGE_DOWN | |PAGE_DOWN |6 +|shift-PAGE_DOWN | |SELECT_PAGE_DOWN |6 +|PAGE_UP | |PAGE_UP |5 +|shift-PAGE_UP | |SELECT_PAGE_UP |5 +|PASTE | |PASTE | +|RIGHT | |MOVE_RIGHT | +|ctrl-RIGHT |linux, win |MOVE_WORD_RIGHT | +|ctrl-shift-RIGHT |linux, win |SELECT_WORD_RIGHT | +|option-RIGHT |mac |MOVE_WORD_RIGHT | +|option-shift-RIGHT |mac |SELECT_WORD_RIGHT | +|shift-RIGHT | |SELECT_RIGHT | +|shift-shortcut-RIGHT |mac |SELECT_PARAGRAPH_END | +|shortcut-RIGHT |mac |MOVE_TO_PARAGRAPH_END | +|TAB | |TAB | +|alt-ctrl-shift-TAB |linux, win |FOCUS_NEXT | +|ctrl-TAB | |FOCUS_NEXT | +|ctrl-shift-TAB | |FOCUS_PREVIOUS | +|ctrl-option-shift-TAB |mac |FOCUS_NEXT | +|shift-TAB | |FOCUS_PREVIOUS | +|UP | |MOVE_UP | +|ctrl-UP |linux, win |MOVE_PARAGRAPH_UP | +|ctrl-shift-UP |linux, win |SELECT_PARAGRAPH_UP | +|option-UP |mac |MOVE_PARAGRAPH_UP | +|option-shift-UP |mac |SELECT_PARAGRAPH_UP | +|shift-UP | |SELECT_UP | +|shift-shortcut-UP | |SELECT_TO_DOCUMENT_START | +|shortcut-UP |mac |MOVE_TO_DOCUMENT_START | +|shortcut-V | |PASTE | +|shift-shortcut-V | |PASTE_PLAIN_TEXT | +|shortcut-X | |CUT | +|ctrl-Y |win |REDO | +|command-shift-Z |mac |REDO | +|ctrl-shift-Z |linux |REDO | +|shortcut-Z | |UNDO | + + +### Other Mappings + +The following functions currently have no mapping: +MOVE_WORD_NEXT_END, MOVE_WORD_NEXT_START, MOVE_WORD_PREVIOUS, SELECT_WORD_NEXT, SELECT_WORD_NEXT_END, SELECT_WORD_PREVIOUS + +The following functions are mapped to the mouse events: +SELECT_PARAGRAPH, SELECT_WORD + + + +### Notes: + +1. On macOS, `alt` is represented by the `option` key +2. On macOS, `shortcut` is represented by the `command` key +3. On macOS, Home = `command` left arrow key +4. On macOS, End = `command` right arrow key +5. On macOS, PgUp = `fn` + `up arrow` key +6. On macOS, PgDn = `fn` + `down arrow` key +7. On macOS, BACKSPACE = `delete` key +8. On macOS, DELETE = `fn` + `delete` key diff --git a/modules/javafx.base/src/main/java/com/sun/javafx/ModuleUtil.java b/modules/javafx.base/src/main/java/com/sun/javafx/ModuleUtil.java new file mode 100644 index 00000000000..f014f894728 --- /dev/null +++ b/modules/javafx.base/src/main/java/com/sun/javafx/ModuleUtil.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.sun.javafx; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.HashSet; +import java.util.Set; + +/** + * Module utilities. + */ +public class ModuleUtil { + + private static final Set warnedModules = new HashSet<>(); + private static final Set warnedPackages = new HashSet<>(); + + private static final Module MODULE_JAVA_BASE = Module.class.getModule(); + + @SuppressWarnings("removal") + public static void incubatorWarning() { + AccessController.doPrivileged((PrivilegedAction) () -> { + var stackWalker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE); + var callerClass = stackWalker.walk(s -> + s.dropWhile(f -> { + var clazz = f.getDeclaringClass(); + return ModuleUtil.class.equals(clazz) || MODULE_JAVA_BASE.equals(clazz.getModule()); + }) + .map(StackWalker.StackFrame::getDeclaringClass) + .findFirst() + .orElseThrow(IllegalStateException::new)); + var callerModule = callerClass.getModule(); + + // If we are using incubating API from the unnamed module, issue + // a warning one time for each package. This is not a supported + // mode, but can happen if the module is placed on the classpath. + if (!callerModule.isNamed()) { + var callerPackage = callerClass.getPackage(); + if (!warnedPackages.contains(callerPackage)) { + System.err.println("WARNING: Using incubating API from an unnamed module: " + callerPackage); + warnedPackages.add(callerPackage); + } + return null; + } + + // TODO: Check whether this module is jlinked into the runtime and + // thus has already printed a warning. + // Issue warning one time for this module + if (!warnedModules.contains(callerModule)) { + System.err.println("WARNING: Using incubator modules: " + callerModule.getName()); + warnedModules.add(callerModule); + } + + return null; + }); + } + + // Prevent instantiation + private ModuleUtil() { + } +} diff --git a/modules/javafx.base/src/main/java/module-info.java b/modules/javafx.base/src/main/java/module-info.java index 4a667bc4d76..3972999bcea 100644 --- a/modules/javafx.base/src/main/java/module-info.java +++ b/modules/javafx.base/src/main/java/module-info.java @@ -47,6 +47,8 @@ exports com.sun.javafx to javafx.controls, + jfx.incubator.input, + jfx.incubator.richtext, javafx.graphics, javafx.fxml, javafx.media, diff --git a/modules/javafx.controls/src/main/resources/com/sun/javafx/scene/control/skin/modena/modena.css b/modules/javafx.controls/src/main/resources/com/sun/javafx/scene/control/skin/modena/modena.css index 05a12f44f3f..4b5c9e45655 100644 --- a/modules/javafx.controls/src/main/resources/com/sun/javafx/scene/control/skin/modena/modena.css +++ b/modules/javafx.controls/src/main/resources/com/sun/javafx/scene/control/skin/modena/modena.css @@ -3438,3 +3438,74 @@ is being used to size a border should also be in pixels. .alert.warning.dialog-pane { -fx-graphic: url("dialog-warning.png"); } + +/******************************************************************************* + * * + * Rich Text Area * + * * + ******************************************************************************/ + +.rich-text-area { + -fx-text-fill: -fx-text-inner-color; + -fx-highlight-fill: derive(-fx-control-inner-background,-20%); + -fx-highlight-text-fill: -fx-text-inner-color; + -fx-prompt-text-fill: derive(-fx-control-inner-background,-30%); + -fx-background-color: + linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), + linear-gradient(from 0px 0px to 0px 5px, derive(-fx-control-inner-background, -9%), -fx-control-inner-background); + -fx-background-insets: 0, 1; + -fx-background-radius: 3, 2; + -fx-cursor: text; + -fx-padding: 0; + -fx-content-padding: 4 8 4 8; +} + +.rich-text-area:disabled { + -fx-opacity: 0.4; +} + +.rich-text-area:focused { + -fx-highlight-fill: -fx-accent; + -fx-highlight-text-fill: white; + -fx-background-color: + -fx-focus-color, + -fx-control-inner-background, + -fx-faint-focus-color, + linear-gradient(from 0px 0px to 0px 5px, derive(-fx-control-inner-background, -9%), -fx-control-inner-background); + -fx-background-insets: -0.2, 1, -1.4, 3; + -fx-background-radius: 3, 2, 4, 0; + -fx-prompt-text-fill: transparent; +} + +.rich-text-area .caret { + -fx-stroke: black; +} + +.rich-text-area .caret-line { + -fx-stroke: null; + -fx-fill: derive(-fx-accent,115%); +} + +.rich-text-area .selection-highlight { + -fx-stroke: null; + -fx-fill: derive(-fx-accent,70%); +} + +.rich-text-area .left-side { + -fx-background-color: rgba(127,127,127,0.1); +} + +.rich-text-area .right-side { + -fx-background-color: rgba(127,127,127,0.1); +} + +.rich-text-area .line-number-decorator { + -fx-font-family: Monospace; + -fx-font-size: 90%; + -fx-min-height:1; + -fx-pref-height:1; +} + +.line-number-decorator .text { + -fx-bounds-type: visual; +} diff --git a/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html index aada927b5c8..3b7e29d2642 100644 --- a/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html +++ b/modules/javafx.graphics/src/main/docs/javafx/scene/doc-files/cssref.html @@ -2,7 +2,7 @@ +

Incubator Modules

+ + + + + + +
jfx.incubator.scene.control.rich
+ +

RichTextArea

+

Style class: rich-text-area

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Available CSS Properties
CSS PropertyValuesDefaultComments
-fx-caret-blink-period<duration>1000 msDetermines the caret blink period.
-fx-content-padding<size>0 0 0 0Amount of padding in the content area.
-fx-display-caret<boolean>trueDetermines whether the caret is displayed.
-fx-highlight-current-paragraph<boolean>falseDetermines whether the current paragraph is highlighted.
-fx-use-content-height<boolean>falseDetermines whether the preferred height is the same as the content height.
-fx-use-content-width<boolean>falseDetermines whether the preferred width is the same as the content width.
-fx-wrap-text<boolean>falseDetermines whether text should be wrapped.
Also has all properties of Control
+

Pseudo-classes

+ + + + + + + + + + + + + + +
Available CSS Pseudo-classes
CSS Pseudo-classComments
Also has all pseudo‑classes of Control
+

Substructure

+
    +
  • main-pane — Pane
  • +
      +
    • vflow — Pane
    • +
        +
      • content — Pane
      • +
          +
        • caret-line — Path
        • +
        • selection-highlight — Path
        • +
        • flow — Pane
        • +
        • caret — Path
        • +
        +
      • left-side — Pane
      • +
      • right-side — Pane
      • +
      +
    • scroll-bar:vertical — ScrollBar
    • +
    • scroll-bar:horizontal — ScrollBar
    • +
    +
+ +

CodeArea

+

The CodeArea control has all the properties of RichTextArea

+

Style class: code-area

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Available CSS Properties
CSS PropertyValuesDefaultComments
-fx-font<font>Monospaced 12pxDetermines the font to use for text.
-fx-line-spacing<number>0 
-fx-tab-size<integer>8 
Also has all properties of RichTextArea
+

Pseudo-classes

+ + + + + + + + + + + + + + +
Available CSS Pseudo-classes
CSS Pseudo-classComments
Also has all pseudo‑classes of RichTextArea
+

References

[1] CSS 2.1: http://www.w3.org/TR/CSS21/

diff --git a/modules/javafx.graphics/src/main/java/module-info.java b/modules/javafx.graphics/src/main/java/module-info.java index 75bf88efd87..7d55d5f37f9 100644 --- a/modules/javafx.graphics/src/main/java/module-info.java +++ b/modules/javafx.graphics/src/main/java/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015, 2022, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2015, 2024, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -102,6 +102,8 @@ javafx.controls; exports com.sun.javafx.scene to javafx.controls, + jfx.incubator.input, + jfx.incubator.richtext, javafx.media, javafx.swing, javafx.web; @@ -117,6 +119,8 @@ javafx.web; exports com.sun.javafx.scene.traversal to javafx.controls, + jfx.incubator.input, + jfx.incubator.richtext, javafx.web; exports com.sun.javafx.sg.prism to javafx.media, @@ -135,6 +139,8 @@ exports com.sun.javafx.util to javafx.controls, javafx.fxml, + jfx.incubator.input, + jfx.incubator.richtext, javafx.media, javafx.swing, javafx.web; diff --git a/modules/jfx.incubator.input/.classpath b/modules/jfx.incubator.input/.classpath new file mode 100644 index 00000000000..dd2f08f78fb --- /dev/null +++ b/modules/jfx.incubator.input/.classpath @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/jfx.incubator.input/.project b/modules/jfx.incubator.input/.project new file mode 100644 index 00000000000..71b58c0b0b5 --- /dev/null +++ b/modules/jfx.incubator.input/.project @@ -0,0 +1,17 @@ + + + incubator.input + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/modules/jfx.incubator.input/.settings/org.eclipse.core.resources.prefs b/modules/jfx.incubator.input/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 00000000000..99f26c0203a --- /dev/null +++ b/modules/jfx.incubator.input/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/modules/jfx.incubator.input/README.md b/modules/jfx.incubator.input/README.md new file mode 100644 index 00000000000..becb358ef4e --- /dev/null +++ b/modules/jfx.incubator.input/README.md @@ -0,0 +1,6 @@ +# Incubator + +This project incubates +[InputMap](src/main/java/javafx/incubator/scene/control/rich/RichTextArea.java) + +Please refer to this [README](/tests/manual/RichTextAreaDemo/README.md). diff --git a/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/EventHandlerPriority.java b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/EventHandlerPriority.java new file mode 100644 index 00000000000..80635984094 --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/EventHandlerPriority.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.sun.jfx.incubator.scene.control.input; + +import java.util.Set; + +/** + * Codifies priority of event handler invocation. + */ +public enum EventHandlerPriority { + USER_HIGH(6000), + USER_KB(5000), + SKIN_KB(4000), + SKIN_HIGH(3000), + SKIN_LOW(2000), + USER_LOW(1000); + + /** set of priorities associated with a {@code Skin} */ + public static final Set ALL_SKIN = Set.of( + SKIN_KB, + SKIN_HIGH, + SKIN_LOW + ); + + final int priority; + + private EventHandlerPriority(int priority) { + this.priority = priority; + } +} diff --git a/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/InputMapHelper.java b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/InputMapHelper.java new file mode 100644 index 00000000000..440cb108e5f --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/InputMapHelper.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.input; + +import com.sun.javafx.util.Utils; +import jfx.incubator.scene.control.input.FunctionTag; +import jfx.incubator.scene.control.input.InputMap; + +/** + * Hides execute() methods in InputMap from the public. + */ +public class InputMapHelper { + public interface Accessor { + public void execute(Object source, InputMap inputMap, FunctionTag tag); + public void executeDefault(Object source, InputMap inputMap, FunctionTag tag); + } + + static { + Utils.forceInit(InputMap.class); + } + + private static Accessor accessor; + + public static void setAccessor(Accessor a) { + if (accessor != null) { + throw new IllegalStateException(); + } + accessor = a; + } + + public static void execute(Object source, InputMap inputMap, FunctionTag tag) { + accessor.execute(source, inputMap, tag); + } + + public static void executeDefault(Object source, InputMap inputMap, FunctionTag tag) { + accessor.executeDefault(source, inputMap, tag); + } +} diff --git a/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/KeyEventMapper.java b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/KeyEventMapper.java new file mode 100644 index 00000000000..31e99e19d66 --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/KeyEventMapper.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.sun.jfx.incubator.scene.control.input; + +import javafx.event.EventType; +import javafx.scene.input.KeyEvent; +import jfx.incubator.scene.control.input.KeyBinding; + +/** + * Contains logic for mapping KeyBinding to a specific KeyEvent. + */ +public class KeyEventMapper { + private static final int PRESSED = 0x01; + private static final int RELEASED = 0x02; + private static final int TYPED = 0x04; + + private int types; + + public KeyEventMapper() { + } + + public EventType addType(KeyBinding k) { + if (k.isKeyPressed()) { + types |= PRESSED; + return KeyEvent.KEY_PRESSED; + } else if (k.isKeyReleased()) { + types |= RELEASED; + return KeyEvent.KEY_RELEASED; + } else { + types |= TYPED; + return KeyEvent.KEY_TYPED; + } + } + + public boolean hasKeyPressed() { + return (types & PRESSED) != 0; + } + + public boolean hasKeyReleased() { + return (types & RELEASED) != 0; + } + + public boolean hasKeyTyped() { + return (types & TYPED) != 0; + } +} diff --git a/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/PHList.java b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/PHList.java new file mode 100644 index 00000000000..4d0e83badc9 --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/com/sun/jfx/incubator/scene/control/input/PHList.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.sun.jfx.incubator.scene.control.input; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import javafx.event.Event; +import javafx.event.EventHandler; + +/** + * Priority Handler List. + * Arranges event handlers according to their EventHandlerPriority. + */ +public class PHList { + /** + * {@code items} is a list of {@code EventHandler}s ordered from high priority to low, + * with each block of same priority prefixed with the priority value. + * Also, USER_KB and SKIN_KB require no handler pointer, so none is added.

+ * Example: + * [ USER_HIGH, handler1, handler2, SKIN_KB, SKIN_LOW, handler3 ] + */ + private final ArrayList items = new ArrayList(4); + + public PHList() { + } + + @Override + public String toString() { + return "PHList" + items; + } + + /** + * Adds an event handler under the given priority. + * A newly added handler will be inserted after previously added handlers with the same priority. + * @param priority the priority + * @param handler the handler to add + */ + public void add(EventHandlerPriority priority, EventHandler handler) { + // positive: simply insert the handler there + // negative: insert priority and the handler if it's not null + int ix = findInsertionIndex(priority); + if (ix < 0) { + ix = -ix - 1; + insert(ix, priority); + // do not store the null handler + if (handler != null) { + insert(++ix, handler); + } + } else { + insert(ix, handler); + } + } + + private void insert(int ix, Object item) { + if (ix < items.size()) { + items.add(ix, item); + } else { + items.add(item); + } + } + + /** + * Removes all the instances of the specified handler. Returns true if the list becomes empty as a result. + * Returns true if the list becomes empty as a result of the removal. + * + * @param the event type + * @param handler the handler to remove + * @return true when the list becomes empty as a result + */ + public boolean remove(EventHandler handler) { + for (int i = 0; i < items.size(); i++) { + Object x = items.get(i); + if (x == handler) { + items.remove(i); + if (isNullOrPriority(i) && isNullOrPriority(i - 1)) { + // remove priority + --i; + items.remove(i); + } + } + } + return items.size() == 0; + } + + private boolean isNullOrPriority(int ix) { + if ((ix >= 0) && (ix < items.size())) { + Object x = items.get(ix); + return (x instanceof EventHandlerPriority); + } + return true; + } + + /** + * Returns the index into {@code items}. + * When the list contains no elements of the given priority, the return value is + * negative, equals to {@code -(insertionIndex + 1)}, + * and the caller must insert the priority value in addition to the handler. + * + * @param priority the priority + * @return the insertion index (positive), or -(insertionIndex + 1) (negative) + */ + private int findInsertionIndex(EventHandlerPriority priority) { + // don't expect many handlers, so linear search is ok + int sz = items.size(); + boolean found = false; + for (int i = 0; i < sz; i++) { + Object x = items.get(i); + if (x instanceof EventHandlerPriority p) { + if (p.priority == priority.priority) { + found = true; + continue; + } else if (p.priority < priority.priority) { + return found ? i : -(i + 1); + } + } + } + return found ? sz : -(sz + 1); + } + + /** + * A client interface for the {@link #forEach(Client)} method. + * @param the event type + */ + @FunctionalInterface + public static interface Client { + /** + * This method gets called for each handler in the order of priority. + * The client may signal to stop iterating by returning false from this method. + * + * @param pri the priority + * @param h the handler (can be null) + * @return true to continue the process, false to stop + */ + public boolean accept(EventHandlerPriority pri, EventHandler h); + } + + /** + * Invokes the {@code client} for each handler in the order of priority. + * @param the event type + * @param client the client reference + */ + public void forEach(Client client) { + EventHandlerPriority pri = null; + boolean stop; + int sz = items.size(); + for (int i = 0; i < sz; i++) { + Object x = items.get(i); + if (x instanceof EventHandlerPriority p) { + pri = p; + if (isNullOrPriority(i + 1)) { + stop = !client.accept(pri, null); + } else { + continue; + } + } else { + // it's a handler, cannot be null + stop = !client.accept(pri, (EventHandler)x); + } + if (stop) { + break; + } + } + } + + /** + * Removes all the entries with the specified priorities. + * @return true if list is empty as a result + */ + public boolean removeHandlers(Set priorities) { + boolean remove = false; + for (int i = 0; i < items.size();) { + Object x = items.get(i); + if (x instanceof EventHandlerPriority p) { + if (priorities.contains(p)) { + remove = true; + items.remove(i); + } else { + remove = false; + i++; + } + } else { + if (remove) { + items.remove(i); + } else { + i++; + } + } + } + return items.size() == 0; + } + + /** + * An internal testing method. + * @param expected the expected internal structure + */ + public void validateInternalState(Object... expected) { + if (!Arrays.equals(expected, items.toArray())) { + throw new RuntimeException("internal mismatch:\nitems=" + items + "\nexpected=" + List.of(expected)); + } + } +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/BehaviorBase.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/BehaviorBase.java new file mode 100644 index 00000000000..44a23f440bc --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/BehaviorBase.java @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jfx.incubator.scene.control.input; + +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.event.EventType; +import javafx.scene.control.Control; +import javafx.scene.input.KeyCode; +import com.sun.javafx.PlatformUtil; +import com.sun.javafx.scene.NodeHelper; +import com.sun.javafx.scene.traversal.Direction; +import com.sun.javafx.scene.traversal.TraversalMethod; + +/** + * Class provides a convenient foundation for the stateful behaviors. + *

+ * A concrete behavior implementation should do the following: + *

    + *
  1. provide default behavior methods (one for each function tag) + *
  2. implement {@link #populateSkinInputMap()} method, in which map control's function tags to + * the behavior methods, map key bindings to the function tags, add additional event handlers, using + * {@link #registerFunction(FunctionTag, Runnable)}, + * {@link #registerKey(KeyBinding, FunctionTag)}, + * {@link #registerKey(KeyCode, FunctionTag)}, + * and + * {@code addHandler()} methods correspondingly. + *
  3. in the corresponding skin's {code Skin.install()}, set the skin input map to the control's input map. + *
+ * Example (in the actual skin class): + *
{@code
+ *     @Override
+ *     public void install() {
+ *         super.install();
+ *         setSkinInputMap(behavior.getSkinInputMap());
+ *   }
+ * }
+ * + * @param the type of the control + * @since 999 TODO + */ +public abstract class BehaviorBase { + private final C control; + private SkinInputMap.Stateful skinInputMap; + + /** + * The constructor. + * @param c the owner Control instance + */ + public BehaviorBase(C c) { + this.control = c; + } + + /** + * In this method, which is called by {@link javafx.scene.control.Skin#install()}, + * the child class populates the {@code SkinInputMap} + * by registering key mappings and event handlers. + *

+ * If a subclass overrides this method, it is important to call the superclass implementation. + */ + protected abstract void populateSkinInputMap(); + + /** + * Returns the associated Control instance. + * @return the owner + */ + protected final C getControl() { + return control; + } + + /** + * Returns the skin input map associated with this behavior. + * @return the input map + */ + public final SkinInputMap.Stateful getSkinInputMap() { + if (skinInputMap == null) { + this.skinInputMap = SkinInputMap.create(); + populateSkinInputMap(); + } + return skinInputMap; + } + + /** + * Maps a function to the specified function tag. + * + * @param tag the function tag + * @param function the function + */ + protected final void registerFunction(FunctionTag tag, Runnable function) { + getSkinInputMap().registerFunction(tag, function); + } + + /** + * Maps a key binding to the specified function tag. + * A null key binding will result in no change to this input map. + * This method will not override a user mapping. + * + * @param k the key binding + * @param tag the function tag + */ + protected final void registerKey(KeyBinding k, FunctionTag tag) { + getSkinInputMap().registerKey(k, tag); + } + + /** + * Maps a key binding to the specified function tag. + * This method will not override a user mapping added by {@link #registerKey(KeyBinding,FunctionTag)}. + * + * @param code the key code to construct a {@link KeyBinding} + * @param tag the function tag + */ + protected final void registerKey(KeyCode code, FunctionTag tag) { + getSkinInputMap().registerKey(code, tag); + } + + /** + * This convenience method maps the function tag to the specified function, and at the same time + * maps the specified key binding to that function tag. + * @param tag the function tag + * @param k the key binding + * @param func the function + */ + protected final void register(FunctionTag tag, KeyBinding k, Runnable func) { + getSkinInputMap().registerFunction(tag, func); + getSkinInputMap().registerKey(k, tag); + } + + /** + * This convenience method maps the function tag to the specified function, and at the same time + * maps the specified key binding to that function tag. + * @param tag the function tag + * @param code the key code + * @param func the function + */ + protected final void register(FunctionTag tag, KeyCode code, Runnable func) { + getSkinInputMap().registerFunction(tag, func); + getSkinInputMap().registerKey(KeyBinding.of(code), tag); + } + + /** + * This convenience method registers a copy of the behavior-specific mappings from one key binding to another. + * The method does nothing if no behavior specific mapping can be found. + * @param existing the existing key binding + * @param newk the new key binding + */ + protected final void duplicateMapping(KeyBinding existing, KeyBinding newk) { + getSkinInputMap().duplicateMapping(existing, newk); + } + + /** + * Adds an event handler for the specified event type, in the context of this Behavior. + * + * @param the actual event type + * @param type the event type + * @param consume determines whether the matching event is consumed or not + * @param handler the event handler + */ + protected final void addHandler(EventType type, boolean consume, EventHandler handler) { + getSkinInputMap().addHandler(type, consume, handler); + } + + /** + * Adds an event handler for the specified event type, in the context of this Behavior. + * This event handler will get invoked after all handlers added via map() methods. + * + * @param the actual event type + * @param type the event type + * @param consume determines whether the matching event is consumed or not + * @param handler the event handler + */ + protected final void addHandlerLast(EventType type, boolean consume, EventHandler handler) { + getSkinInputMap().addHandler(type, consume, handler); + } + + /** + * Adds an event handler for the specific event criteria, in the context of this Behavior. + * This is a more specific version of {@link #addHandler(EventType,boolean,EventHandler)} method. + * + * @param the actual event type + * @param criteria the matching criteria + * @param consume determines whether the matching event is consumed or not + * @param handler the event handler + */ + protected final void addHandler(EventCriteria criteria, boolean consume, EventHandler handler) { + getSkinInputMap().addHandler(criteria, consume, handler); + } + + /** + * Adds an event handler for the specific event criteria, in the context of this Behavior. + * This event handler will get invoked after all handlers added via map() methods. + * + * @param the actual event type + * @param criteria the matching criteria + * @param consume determines whether the matching event is consumed or not + * @param h the event handler + */ + protected final void addHandlerLast(EventCriteria criteria, boolean consume, EventHandler h) { + getSkinInputMap().addHandler(criteria, consume, h); + } + + /** + * Returns true if this method is invoked on a Linux platform. + * @return true on a Linux platform + */ + protected final boolean isLinux() { + return PlatformUtil.isLinux(); + } + + /** + * Returns true if this method is invoked on a Mac OS platform. + * @return true on a Mac OS platform + */ + protected final boolean isMac() { + return PlatformUtil.isMac(); + } + + /** + * Returns true if this method is invoked on a Windows platform. + * @return true on a Windows platform + */ + protected final boolean isWindows() { + return PlatformUtil.isWindows(); + } + + /** + * Called by any of the BehaviorBase traverse methods to actually effect a + * traversal of the focus. The default behavior of this method is to simply + * traverse on the given node, passing the given direction. A + * subclass may override this method. + * + * @param dir The direction to traverse + */ + // NOTE: there should be a proper public focus management API + private void traverse(Direction dir) { + NodeHelper.traverse(control, dir, TraversalMethod.KEY); + } + + /** + * Calls the focus traversal engine and indicates that traversal should + * go the next focusTraversable Node above the current one. + */ + protected final void traverseUp() { + traverse(Direction.UP); + } + + /** + * Calls the focus traversal engine and indicates that traversal should + * go the next focusTraversable Node below the current one. + */ + protected final void traverseDown() { + traverse(Direction.DOWN); + } + + /** + * Calls the focus traversal engine and indicates that traversal should + * go the next focusTraversable Node left of the current one. + */ + protected final void traverseLeft() { + traverse(Direction.LEFT); + } + + /** + * Calls the focus traversal engine and indicates that traversal should + * go the next focusTraversable Node right of the current one. + */ + protected final void traverseRight() { + traverse(Direction.RIGHT); + } + + /** + * Calls the focus traversal engine and indicates that traversal should + * go the next focusTraversable Node in the focus traversal cycle. + */ + protected final void traverseNext() { + traverse(Direction.NEXT); + } + + /** + * Calls the focus traversal engine and indicates that traversal should + * go the previous focusTraversable Node in the focus traversal cycle. + */ + protected final void traversePrevious() { + traverse(Direction.PREVIOUS); + } +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/EventCriteria.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/EventCriteria.java new file mode 100644 index 00000000000..787c831aef4 --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/EventCriteria.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jfx.incubator.scene.control.input; + +import javafx.event.Event; +import javafx.event.EventType; + +/** + * Determines whether an event passes certain criteria. + * + * @param the type of the event + * @since 999 TODO + */ +public interface EventCriteria { + /** + * Returns the event type for which this criteria are valid. + * @return the event type + */ + public EventType getEventType(); + + /** + * Returns true if the specified event matches this criteria. + * @param ev the event + * @return true if match occurs + */ + public boolean isEventAcceptable(T ev); +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/FunctionHandler.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/FunctionHandler.java new file mode 100644 index 00000000000..be96e7ecf4a --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/FunctionHandler.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jfx.incubator.scene.control.input; + +/** + * A functional interface which denotes code associated with a {@code FunctionTag} or a key binding. + * This handler allows for controlling whether the matching event + * will be consumed or not. + * + * @since 999 TODO + */ +@FunctionalInterface +public interface FunctionHandler { + /** + * Handles the event associated with a function tag or a key binding. + * @return true to consume the key event, false otherwise + */ + public boolean handleFunction(); +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/FunctionTag.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/FunctionTag.java new file mode 100644 index 00000000000..97aa7598f4d --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/FunctionTag.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.input; + +import com.sun.javafx.ModuleUtil; + +/** + * A function tag is a public handle for a function in the context of InputMap. + * + * @since 999 TODO + */ +public final class FunctionTag { + /** Constructs the function tag. */ + public FunctionTag() { + } + + static { ModuleUtil.incubatorWarning(); } +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/InputMap.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/InputMap.java new file mode 100644 index 00000000000..d6844da5339 --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/InputMap.java @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jfx.incubator.scene.control.input; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.event.EventType; +import javafx.scene.control.Control; +import javafx.scene.control.Skinnable; +import javafx.scene.input.KeyEvent; +import com.sun.javafx.ModuleUtil; +import com.sun.jfx.incubator.scene.control.input.EventHandlerPriority; +import com.sun.jfx.incubator.scene.control.input.InputMapHelper; +import com.sun.jfx.incubator.scene.control.input.KeyEventMapper; +import com.sun.jfx.incubator.scene.control.input.PHList; + +/** + * InputMap is a class that is set on a given {@link Control}. When the Node receives + * an input event from the system, it passes this event in to the InputMap where + * the InputMap can check all installed mappings to see if there is any + * suitable mapping, and if so, fire the provided {@link EventHandler}. + * + * @since 999 TODO + */ +public final class InputMap { + private static final Object NULL = new Object(); + private final Control control; + /** + *

 KeyBinding -> FunctionTag or Runnable
+     * FunctionTag -> Runnable
+     * EventType -> PHList
+ */ + private final HashMap map = new HashMap<>(); + private SkinInputMap skinInputMap; + private final KeyEventMapper kmapper = new KeyEventMapper(); + private final EventHandler eventHandler = this::handleEvent; + + static { + ModuleUtil.incubatorWarning(); + initAccessor(); + } + + /** + * The constructor. + * @param control the owner control + */ + public InputMap(Control control) { + this.control = control; + } + + /** + * Adds an event handler for the specified event type, at the control level. + * This mapping always consumes the matching event. + * + * @param the actual event type + * @param type the event type + * @param handler the event handler + */ + public void addHandler(EventType type, EventHandler handler) { + extendHandler(type, handler, EventHandlerPriority.USER_HIGH); + } + + /** + * Adds an event handler for the specified event type, at the control level. + * This event handler will get invoked after all handlers added via map() methods. + * This mapping always consumes the matching event. + * + * @param the actual event type + * @param type the event type + * @param handler the event handler + */ + public void addHandlerLast(EventType type, EventHandler handler) { + extendHandler(type, handler, EventHandlerPriority.USER_LOW); + } + + /** + * Removes the specified handler. + * + * @param the event class + * @param type the event type + * @param handler the handler to remove + */ + public void removeHandler(EventType type, EventHandler handler) { + Object x = map.get(type); + if (x instanceof PHList hs) { + if (hs.remove(handler)) { + map.remove(type); + control.removeEventHandler(type, eventHandler); + } + } + } + + private void removeHandler(EventType type, EventHandlerPriority pri) { + Object x = map.get(type); + if (x instanceof PHList hs) { + if (hs.removeHandlers(Set.of(pri))) { + map.remove(type); + control.removeEventHandler(type, eventHandler); + } + } + } + + private void extendHandler(EventType t, EventHandler handler, EventHandlerPriority pri) { + Object x = map.get(t); + PHList hs; + if (x instanceof PHList h) { + hs = h; + } else { + // first entry for this event type + hs = new PHList(); + map.put(t, hs); + control.addEventHandler(t, eventHandler); + } + + hs.add(pri, handler); + } + + private void handleEvent(Event ev) { + // probably unnecessary + if (ev == null || ev.isConsumed()) { + return; + } + + EventType t = ev.getEventType(); + Object x = map.get(t); + if (x instanceof PHList hs) { + hs.forEach((pri, h) -> { + if (h == null) { + handleKeyBindingEvent(ev); + } else { + h.handle(ev); + } + return !ev.isConsumed(); + }); + } + } + + private void handleKeyBindingEvent(Event ev) { + // probably unnecessary + if (ev == null || ev.isConsumed()) { + return; + } + + KeyBinding k = KeyBinding.from((KeyEvent)ev); + if (k != null) { + boolean consume = execute(ev.getSource(), k); + if (consume) { + ev.consume(); + } + } + } + + private boolean execute(Object source, KeyBinding k) { + Object x = resolve(k); + if (x instanceof FunctionTag tag) { + return execute(source, tag); + } else if (x instanceof FunctionHandler h) { + return h.handleFunction(); + } else if (x instanceof Runnable r) { + r.run(); + return true; + } + return false; + } + + // package protected to prevent unauthorized code to supply wrong instance of control (source) + boolean execute(Object source, FunctionTag tag) { + Object x = map.get(tag); + if (x instanceof Runnable r) { + r.run(); + return true; + } + + return executeDefault(source, tag); + } + + // package protected to prevent unauthorized code to supply wrong instance of control (source) + boolean executeDefault(Object source, FunctionTag tag) { + if (skinInputMap != null) { + return skinInputMap.execute(source, tag); + } + return false; + } + + private Object resolve(KeyBinding k) { + Object x = map.get(k); + if (x != null) { + return x; + } + if (skinInputMap != null) { + return skinInputMap.resolve(k); + } + return null; + } + + /** + * Registers a function for the given key binding. This mapping will take precedence + * over any such mapping set by the skin. + * + * @param k the key binding + * @param function the function + */ + public void register(KeyBinding k, Runnable function) { + Objects.requireNonNull(k, "key binding must not be null"); + Objects.requireNonNull(function, "function must not be null"); + map.put(k, function); + } + + /** + * Adds (or overrides) a user-specified function under the given function tag. + * This function will take precedence over any function set by the skin. + * + * @param tag the function tag + * @param function the function + */ + public void registerFunction(FunctionTag tag, Runnable function) { + Objects.requireNonNull(tag, "function tag must not be null"); + Objects.requireNonNull(function, "function must not be null"); + map.put(tag, function); + } + + /** + * Link a key binding to the specified function tag. + * When the key binding matches the input event, the function is executed, the event is consumed, + * and the process of dispatching is stopped. + *

+ * This method will take precedence over any function set by the skin. + * + * @param k the key binding + * @param tag the function tag + */ + public void registerKey(KeyBinding k, FunctionTag tag) { + Objects.requireNonNull(k, "KeyBinding must not be null"); + Objects.requireNonNull(tag, "function tag must not be null"); + map.put(k, tag); + + EventType t = kmapper.addType(k); + extendHandler(t, null, EventHandlerPriority.USER_KB); + } + + /** + * Unbinds the specified key binding. + * + * @param k the key binding + */ + public void unbind(KeyBinding k) { + map.put(k, NULL); + } + + /** + * Reverts all the key bindings set by user. + * This method restores key bindings set by the skin which were overwritten by the user. + */ + public void resetKeyBindings() { + Iterator> it = map.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry me = it.next(); + if (me.getKey() instanceof KeyBinding) { + it.remove(); + } + } + } + + /** + * Restores the specified key binding to the value set by the behavior, if any. + * + * @param k the key binding + */ + public void restoreDefaultKeyBinding(KeyBinding k) { + Object x = map.get(k); + if (x != null) { + map.remove(k); + } + } + + /** + * Restores the specified function tag to the value set by the behavior, if any. + * + * @param tag the function tag + */ + public void restoreDefaultFunction(FunctionTag tag) { + Objects.requireNonNull(tag, "function tag must not be null"); + map.remove(tag); + } + + /** + * Collects all mapped key bindings (set either by the user or the behavior). + * @return the set of key bindings + */ + public Set getKeyBindings() { + return collectKeyBindings(null); + } + + /** + * Returns the set of key bindings mapped to the specified function tag. + * @param tag the function tag + * @return the set of KeyBindings, non-null + */ + public Set getKeyBindingsFor(FunctionTag tag) { + return collectKeyBindings(tag); + } + + // null tag collects all bindings + private Set collectKeyBindings(FunctionTag tag) { + HashSet bindings = new HashSet<>(); + for (Map.Entry en : map.entrySet()) { + if (en.getKey() instanceof KeyBinding k) { + if ((tag == null) || (tag == en.getValue())) { + bindings.add(k); + } + } + } + + if (skinInputMap != null) { + skinInputMap.collectKeyBindings(bindings, tag); + } + return bindings; + } + + /** + * Removes all the key bindings mapped to the specified function tag, either by the application or by the skin. + * This is an irreversible operation. + * @param tag the function tag + */ + // TODO this should not affect the skin input map, but perhaps place NULL for each found KeyBinding + public void unbind(FunctionTag tag) { + if (skinInputMap != null) { + skinInputMap.unbind(tag); + } + Iterator> it = map.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry en = it.next(); + if (tag == en.getValue()) { + // the entry must be KeyBinding -> FunctionTag + if (en.getKey() instanceof KeyBinding) { + it.remove(); + } + } + } + } + + /** + * Sets the skin input map, adding necessary event handlers to the control instance when required. + * This method must be called by the skin only from its + * {@link javafx.scene.control.Skin#install() Skin.install()} + * method. + *

+ * This method removes all the mappings from the previous skin input map, if any. + * @param m the skin input map + */ + public void setSkinInputMap(SkinInputMap m) { + if (skinInputMap != null) { + // uninstall all handlers with SKIN_* priority + Iterator> it = map.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry en = it.next(); + if (en.getKey() instanceof EventType t) { + PHList hs = (PHList)en.getValue(); + if (hs.removeHandlers(EventHandlerPriority.ALL_SKIN)) { + it.remove(); + control.removeEventHandler(t, eventHandler); + } + } + } + } + + skinInputMap = m; + + if (skinInputMap != null) { + // install skin handlers with their priority + skinInputMap.forEach((type, pri, h) -> { + extendHandler(type, h, pri); + }); + + // add key bindings listeners if needed + if (!kmapper.hasKeyPressed() && skinInputMap.kmapper.hasKeyPressed()) { + extendHandler(KeyEvent.KEY_PRESSED, null, EventHandlerPriority.SKIN_KB); + } + if (!kmapper.hasKeyReleased() && skinInputMap.kmapper.hasKeyReleased()) { + extendHandler(KeyEvent.KEY_RELEASED, null, EventHandlerPriority.SKIN_KB); + } + if (!kmapper.hasKeyTyped() && skinInputMap.kmapper.hasKeyTyped()) { + extendHandler(KeyEvent.KEY_TYPED, null, EventHandlerPriority.SKIN_KB); + } + } + } + + private static void initAccessor() { + InputMapHelper.setAccessor(new InputMapHelper.Accessor() { + // will be unnecessary after JDK-8314968 + @Override + public void executeDefault(Object source, InputMap inputMap, FunctionTag tag) { + inputMap.executeDefault(source, tag); + } + + // will be unnecessary after JDK-8314968 + @Override + public void execute(Object source, InputMap inputMap, FunctionTag tag) { + inputMap.execute(source, tag); + } + }); + } +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/KeyBinding.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/KeyBinding.java new file mode 100644 index 00000000000..df134e32dac --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/KeyBinding.java @@ -0,0 +1,753 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.input; + +import java.util.EnumSet; +import java.util.Objects; +import javafx.event.EventType; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import com.sun.javafx.PlatformUtil; + +/** + * Key binding provides a way to map key event to a hash table key for easy matching. + * + * @since 999 TODO + */ +public class KeyBinding implements EventCriteria { + /** + * Condition used to build input key mappings. + *

+ * The KCondition values are used as keys in a hash table, so when the platform sends a key event with multiple + * modifiers, some modifiers are dropped in order to make the final key binding to function lookup unambiguous. + *

+ * The mapping is as follows: + *

+     * KCondition    Mac         Windows/Linux
+     * ALT           OPTION      ALT
+     * COMMAND       COMMAND     (ignored)
+     * CTRL          CTRL        CTRL
+     * META          COMMAND     META
+     * OPTION        OPTION      (ignored)
+     * SHIFT         SHIFT       SHIFT
+     * SHORTCUT      COMMAND     CTRL
+     * WINDOWS       (ignored)   META
+     * 
+ */ + private enum KCondition { + // modifier keys + /** ALT modifier, mapped to OPTION on Mac, ALT on Windows/Linux */ + ALT, + /** COMMAND modifier, mapped to COMMAND on Mac only */ + COMMAND, + /** CTRL modifier */ + CTRL, + /** META modifier, mapped to COMMAND on Mac, META on Windows/Linux */ + META, + /** OPTION modifier, mapped to OPTION on Mac only */ + OPTION, + /** SHIFT modifier */ + SHIFT, + /** SHORTCUT modifier, mapped to COMMAND on Mac, CTRL on Windows/Linux */ + SHORTCUT, + /** Windows key modifier (⊞), mapped to WINDOWS on Windows only */ + WINDOWS, + + // event types + /** a key pressed event */ + KEY_PRESSED, + /** a key released event */ + KEY_RELEASED, + /** a key typed event */ + KEY_TYPED, + } + + private final Object key; // KeyCode or String + private final EnumSet modifiers; + + private KeyBinding(Object key, EnumSet modifiers) { + this.key = key; + this.modifiers = modifiers; + } + + /** + * Utility method creates a KeyBinding corresponding to a key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding of(KeyCode code) { + return create(code, KCondition.KEY_PRESSED); + } + + /** + * Utility method creates a KeyBinding corresponding to a command-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding command(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.COMMAND); + } + + /** + * Utility method creates a KeyBinding corresponding to a alt-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding alt(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.ALT); + } + + /** + * Utility method creates a KeyBinding corresponding to a ctrl-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding ctrl(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.CTRL); + } + + /** + * Utility method creates a KeyBinding corresponding to a ctrl-shift-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding ctrlShift(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.CTRL, KCondition.SHIFT); + } + + /** + * Utility method creates a KeyBinding corresponding to an option-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding option(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.OPTION); + } + + /** + * Utility method creates a KeyBinding corresponding to a shift-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding shift(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.SHIFT); + } + + /** + * Utility method creates a KeyBinding corresponding to a shortcut-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding shortcut(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.SHORTCUT); + } + + /** + * Utility method creates a KeyBinding corresponding to a shift-option-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding shiftOption(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.SHIFT, KCondition.OPTION); + } + + /** + * Utility method creates a KeyBinding corresponding to a shift-shortcut-code key press. + * + * @param code the key code + * @return the KeyBinding + */ + public static KeyBinding shiftShortcut(KeyCode code) { + return create(code, KCondition.KEY_PRESSED, KCondition.SHIFT, KCondition.SHORTCUT); + } + + private static KeyBinding create(Object key, KCondition... mods) { + return new Builder(key).init(mods).build(); + } + + /** + * Determines whether this key binding if for the key pressed event. + * @return true if this key binding if for the key press event + */ + public boolean isKeyPressed() { + return modifiers.contains(KCondition.KEY_PRESSED); + } + + /** + * Determines whether this key binding if for the key released event. + * @return true if this key binding if for the key release event + */ + public boolean isKeyReleased() { + return modifiers.contains(KCondition.KEY_RELEASED); + } + + /** + * Determines whether this key binding if for the key typed event. + * @return true if this key binding if for the key typed event + */ + public boolean isKeyTyped() { + return modifiers.contains(KCondition.KEY_TYPED); + } + + /** + * Determines whether {@code shortcut} key is down in this key binding. + * @return true if {@code shortcut} key is down in this key binding + */ + public boolean isShortcut() { + if (PlatformUtil.isMac()) { + return modifiers.contains(KCondition.COMMAND); + } + return modifiers.contains(KCondition.CTRL); + } + + /** + * Determines whether {@code alt} key is down in this key binding. + * @return true if {@code alt} key is down in this key binding + */ + public boolean isAlt() { + return modifiers.contains(KCondition.ALT); + } + + /** + * Determines whether {@code control} key is down in this key binding. + * @return true if {@code control} key is down in this key binding + */ + public boolean isCtrl() { + return modifiers.contains(KCondition.CTRL); + } + + /** + * Determines whether {@code control} key is down in this key binding. + * Applies to macOS platform only. + * @return true if {@code control} key is down in this key binding + */ + public boolean isCommand() { + return modifiers.contains(KCondition.COMMAND); + } + + /** + * Determines whether {@code meta} key is down in this key binding. + * @return true if {@code meta} key is down in this key binding + */ + public boolean isMeta() { + return modifiers.contains(KCondition.META); + } + + /** + * Determines whether {@code option} key is down in this key binding. + * Applies to macOS only. + * @return true if {@code option} key is down in this key binding + */ + public boolean isOption() { + return modifiers.contains(KCondition.OPTION); + } + + /** + * Determines whether {@code shift} key is down in this key binding. + * @return true if {@code shift} key is down in this key binding + */ + public boolean isShift() { + return modifiers.contains(KCondition.SHIFT); + } + + /** + * Returns a {@link KeyCode} or null if the key binding is not for a key code. + * @return key code + */ + public KeyCode getKeyCode() { + if (key instanceof KeyCode c) { + return c; + } + return null; + } + + /** + * Creates a {@link Builder} with the specified KeyCode. + * @param code the key code + * @return the Builder instance + */ + public static Builder builder(KeyCode code) { + return new Builder(code); + } + + /** + * Creates a {@link Builder} with the specified KeyCode. + * @param character the character + * @return the Builder instance + */ + public static Builder builder(String character) { + return new Builder(character); + } + + @Override + public int hashCode() { + int h = KeyBinding.class.hashCode(); + h = 31 * h + key.hashCode(); + h = 31 * h + modifiers.hashCode(); + return h; + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof KeyBinding k) { + return + Objects.equals(key, k.key) && + modifiers.equals(k.modifiers); + } + return false; + } + + /** + * Creates a Builder with a key pressed event. + * @param c key code + * @return Builder instance + */ + public static Builder with(KeyCode c) { + return builder(c); + } + + /** + * Creates a Builder with a key pressed event. + * @param c character pressed + * @return Builder instance + */ + public static Builder with(String c) { + return new Builder(c); + } + + /** + * Creates a KeyBinding from a KeyEvent, or a null if the event does not correspond to a valid KeyBinding. + * @param ev the key event + * @return the key binding, or null + */ + static KeyBinding from(KeyEvent ev) { + Object key; + EnumSet m = EnumSet.noneOf(KCondition.class); + EventType t = ev.getEventType(); + if(t == KeyEvent.KEY_PRESSED) { + m.add(KCondition.KEY_PRESSED); + key = ev.getCode(); + } else if(t == KeyEvent.KEY_RELEASED) { + m.add(KCondition.KEY_RELEASED); + key = ev.getCode(); + } else if(t == KeyEvent.KEY_TYPED) { + m.add(KCondition.KEY_TYPED); + key = ev.getCharacter(); + } else { + return null; + } + + boolean alt = ev.isAltDown(); + boolean ctrl = ev.isControlDown(); + boolean meta = ev.isMetaDown(); + boolean shortcut = ev.isShortcutDown(); + boolean option = false; + boolean command = false; + + boolean mac = PlatformUtil.isMac(); + boolean win = PlatformUtil.isWindows(); + + // drop multiple modifiers, translating when necessary + + if (mac) { + if (alt) { + alt = false; + option = true; + } + if (shortcut) { + meta = false; + command = true; + } + } else { + if (ctrl) { + shortcut = false; + } + } + + if (alt) { + m.add(KCondition.ALT); + } + + if (command) { + m.add(KCondition.COMMAND); + } + + if (ctrl) { + m.add(KCondition.CTRL); + } + + if (meta) { + m.add(KCondition.META); + } + + if (option) { + m.add(KCondition.OPTION); + } + + if (ev.isShiftDown()) { + m.add(KCondition.SHIFT); + } + + KeyBinding keyBinding = new KeyBinding(key, m); + //System.err.println("kb=" + keyBinding + " ev=" + toString(ev)); // FIX + return keyBinding; + } + + // FIX remove, debug +// private static String toString(KeyEvent ev) { +// StringBuilder sb = new StringBuilder("KeyEvent{"); +// sb.append("type=").append(ev.getEventType()); +// sb.append(", char=").append(ev.getCharacter()); +// +// String ch = ev.getCharacter(); +// int sz = ch.length(); +// if (sz > 0) { +// sb.append("("); +// for (int i = 0; i < ch.length(); i++) { +// sb.append(String.format("%02X", (int)ch.charAt(i))); +// } +// sb.append(")"); +// } +// +// sb.append(", code=").append(ev.getCode()); +// +// if (ev.isShiftDown()) { +// sb.append(", shift"); +// } +// if (ev.isControlDown()) { +// sb.append(", control"); +// } +// if (ev.isAltDown()) { +// sb.append(", alt"); +// } +// if (ev.isMetaDown()) { +// sb.append(", meta"); +// } +// if (ev.isShortcutDown()) { +// sb.append(", shortcut"); +// } +// +// return sb.append("}").toString(); +// } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("KeyBinding{key="); + sb.append(key); + sb.append(", modifiers="); + sb.append(modifiers); + sb.append("}"); + return sb.toString(); + } + + /** + * Returns the event type for this key binding. + * @return KeyEvent + */ + @Override + public EventType getEventType() { + if (isKeyPressed()) { + return KeyEvent.KEY_PRESSED; + } else if (isKeyReleased()) { + return KeyEvent.KEY_RELEASED; + } else { + return KeyEvent.KEY_TYPED; + } + } + + @Override + public boolean isEventAcceptable(KeyEvent ev) { + return equals(KeyBinding.from(ev)); + } + + /** Key bindings builder */ + public static class Builder { + private final Object key; // KeyCode or String + private final EnumSet m = EnumSet.noneOf(KCondition.class); + + /** Constructs a Builder */ + Builder(Object key) { + this.key = key; + } + + /** + * Sets on KEY_RELEASED condition. + * @return the Builder instance + */ + public Builder onKeyReleased() { + m.remove(KCondition.KEY_PRESSED); + m.remove(KCondition.KEY_TYPED); + m.add(KCondition.KEY_RELEASED); + return this; + } + + /** + * Sets on KEY_TYPED condition. + * @return the Builder instance + */ + public Builder onKeyTyped() { + m.remove(KCondition.KEY_PRESSED); + m.add(KCondition.KEY_TYPED); + m.remove(KCondition.KEY_RELEASED); + return this; + } + + /** + * Sets the {@code alt} key down condition (the {@code Option} key on macOS). + * @return this Builder + */ + public Builder alt() { + m.add(KCondition.ALT); + return this; + } + + /** + * Sets the {@code alt} key down condition (the {@code Option} key on macOS). + * @param on condition + * @return this Builder + */ + public Builder alt(boolean on) { + if (on) { + m.add(KCondition.ALT); + } + return this; + } + + /** + * Sets {@code command} key down condition. + * @return this Builder + */ + public Builder command() { + m.add(KCondition.COMMAND); + return this; + } + + /** + * Sets {@code command} key down condition. + * @param on condition + * @return this Builder + */ + public Builder command(boolean on) { + if (on) { + m.add(KCondition.COMMAND); + } + return this; + } + + /** + * Sets {@code control} key down condition. + * @return this Builder + */ + public Builder ctrl() { + m.add(KCondition.CTRL); + return this; + } + + /** + * Sets {@code control} key down condition. + * @param on condition + * @return this Builder + */ + public Builder ctrl(boolean on) { + if (on) { + m.add(KCondition.CTRL); + } + return this; + } + + /** + * Sets {@code meta} key down condition. + * @return this Builder + */ + public Builder meta() { + m.add(KCondition.META); + return this; + } + + /** + * Sets {@code meta} key down condition. + * @param on condition + * @return this Builder + */ + public Builder meta(boolean on) { + if (on) { + m.add(KCondition.META); + } + return this; + } + + /** + * Sets {@code option} key down condition. + * @return this Builder + */ + public Builder option() { + m.add(KCondition.OPTION); + return this; + } + + /** + * Sets {@code option} key down condition. + * @param on condition + * @return this Builder + */ + public Builder option(boolean on) { + if (on) { + m.add(KCondition.OPTION); + } + return this; + } + + /** + * Sets {@code shift} key down condition. + * @return this Builder + */ + public Builder shift() { + m.add(KCondition.SHIFT); + return this; + } + + /** + * Sets {@code shift} key down condition. + * @param on condition + * @return this Builder + */ + public Builder shift(boolean on) { + if (on) { + m.add(KCondition.SHIFT); + } + return this; + } + + /** + * Sets {@code shortcut} key down condition. + * @return this Builder + */ + public Builder shortcut() { + m.add(KCondition.SHORTCUT); + return this; + } + + /** + * Sets {@code shortcut} key down condition. + * @param on condition + * @return this Builder + */ + public Builder shortcut(boolean on) { + if (on) { + m.add(KCondition.SHORTCUT); + } + return this; + } + + private Builder init(KCondition... mods) { + for (KCondition c : mods) { + m.add(c); + } + return this; + } + + private void replace(KCondition c, KCondition replaceWith) { + if (m.contains(c)) { + m.remove(c); + m.add(replaceWith); + } + } + + /** + * Creates a new {@link KeyBinding} instance. + * @return a new key binding instance. + */ + public KeyBinding build() { + boolean mac = PlatformUtil.isMac(); + boolean win = PlatformUtil.isWindows(); + boolean linux = PlatformUtil.isLinux(); + + if (mac) { + replace(KCondition.ALT, KCondition.OPTION); + replace(KCondition.META, KCondition.COMMAND); + replace(KCondition.SHORTCUT, KCondition.COMMAND); + } else if (win) { + replace(KCondition.SHORTCUT, KCondition.CTRL); + } else if (linux) { + replace(KCondition.SHORTCUT, KCondition.CTRL); + } + + if (!mac) { + if (m.contains(KCondition.COMMAND)) { + return null; + } else if (m.contains(KCondition.OPTION)) { + return null; + } + + replace(KCondition.WINDOWS, KCondition.META); + } + + boolean pressed = m.contains(KCondition.KEY_PRESSED); + boolean released = m.contains(KCondition.KEY_RELEASED); + boolean typed = m.contains(KCondition.KEY_TYPED); + + int ct = 0; + KCondition t = null; + if (pressed) { + ct++; + t = KCondition.KEY_PRESSED; + } + if (released) { + ct++; + t = KCondition.KEY_RELEASED; + } + if (typed) { + ct++; + t = KCondition.KEY_TYPED; + } + + // validate event type + if (ct > 1) { + throw new IllegalArgumentException("more than one key event type is specified"); + } + + if (t == null) { + t = KCondition.KEY_PRESSED; + } + m.add(t); + + // TODO validate: shortcut and !(other shortcut modifier) + return new KeyBinding(key, m); + } + } +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/SkinInputMap.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/SkinInputMap.java new file mode 100644 index 00000000000..0790f8f7475 --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/SkinInputMap.java @@ -0,0 +1,438 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jfx.incubator.scene.control.input; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.event.EventType; +import javafx.scene.control.Control; +import javafx.scene.input.KeyCode; +import com.sun.jfx.incubator.scene.control.input.EventHandlerPriority; +import com.sun.jfx.incubator.scene.control.input.KeyEventMapper; +import com.sun.jfx.incubator.scene.control.input.PHList; + +/** + * The Input Map for use by the Skin. + *

+ * Skins whose behavior encapsulates state information must use a Stateful variant obtained with + * the {@link #create()} factory method. + *

+ * Skins whose behavior requires no state, or when state is fully encapsulated by the Control itself, + * could use a Stateless variant obtained with the {@link #createStateless()} method. + * + * @since 999 TODO + */ +public abstract sealed class SkinInputMap permits SkinInputMap.Stateful, SkinInputMap.Stateless { + /** + *

 KeyBinding -> FunctionTag
+     * FunctionTag -> Runnable or FunctionHandler
+     * EventType -> PHList
+ */ + final HashMap map = new HashMap<>(); + final KeyEventMapper kmapper = new KeyEventMapper(); + + /** + * Creates a skin input map. + */ + public SkinInputMap() { + } + + /** + * Adds an event handler for the specified event type, in the context of this skin. + * + * @param the actual event type + * @param type the event type + * @param consume determines whether the matching event is consumed or not + * @param handler the event handler + */ + public final void addHandler(EventType type, boolean consume, EventHandler handler) { + addHandler(type, consume, EventHandlerPriority.SKIN_HIGH, handler); + } + + /** + * Adds an event handler for the specified event type, in the context of this skin. + * This event handler will get invoked after all handlers added via map() methods. + * + * @param the actual event type + * @param type the event type + * @param consume determines whether the matching event is consumed or not + * @param handler the event handler + */ + public final void addHandlerLast(EventType type, boolean consume, EventHandler handler) { + addHandler(type, consume, EventHandlerPriority.SKIN_LOW, handler); + } + + /** + * Adds an event handler for the specific event criteria, in the context of this skin. + * This is a more specific version of {@link #addHandler(EventType,boolean,EventHandler)} method. + * + * @param the actual event type + * @param criteria the matching criteria + * @param consume determines whether the matching event is consumed or not + * @param handler the event handler + */ + public final void addHandler(EventCriteria criteria, boolean consume, EventHandler handler) { + addHandler(criteria, consume, EventHandlerPriority.SKIN_HIGH, handler); + } + + /** + * Adds an event handler for the specific event criteria, in the context of this skin. + * This event handler will get invoked after all handlers added via map() methods. + * + * @param the actual event type + * @param criteria the matching criteria + * @param consume determines whether the matching event is consumed or not + * @param h the event handler + */ + public final void addHandlerLast(EventCriteria criteria, boolean consume, EventHandler h) { + addHandler(criteria, consume, EventHandlerPriority.SKIN_LOW, h); + } + + private void addHandler( + EventType type, + boolean consume, + EventHandlerPriority pri, + EventHandler handler) + { + if (consume) { + putHandler(type, pri, new EventHandler() { + @Override + public void handle(T ev) { + handler.handle(ev); + ev.consume(); + } + }); + } else { + putHandler(type, pri, handler); + } + } + + private void addHandler( + EventCriteria criteria, + boolean consume, + EventHandlerPriority pri, + EventHandler handler) + { + EventType type = criteria.getEventType(); + putHandler(type, pri, new EventHandler() { + @Override + public void handle(T ev) { + if (criteria.isEventAcceptable(ev)) { + handler.handle(ev); + if (consume) { + ev.consume(); + } + } + } + }); + } + + // adds the specified handler to input map with the given priority + // and event type. + private void putHandler(EventType type, EventHandlerPriority pri, EventHandler handler) { + Object x = map.get(type); + PHList hs; + if (x instanceof PHList h) { + hs = h; + } else { + hs = new PHList(); + map.put(type, hs); + } + hs.add(pri, handler); + } + + /** + * Maps a key binding to the specified function tag. + * + * @param k the key binding + * @param tag the function tag + */ + public final void registerKey(KeyBinding k, FunctionTag tag) { + map.put(k, tag); + kmapper.addType(k); + } + + /** + * Maps a key binding to the specified function tag. + * + * @param code the key code to construct a {@link KeyBinding} + * @param tag the function tag + */ + public final void registerKey(KeyCode code, FunctionTag tag) { + registerKey(KeyBinding.of(code), tag); + } + + Object resolve(KeyBinding k) { + return map.get(k); + } + + /** + * Collects the key bindings mapped by the skin. + * + * @return a Set of key bindings + */ + public final Set getKeyBindings() { + return collectKeyBindings(null, null); + } + + /** + * Returns the set of key bindings mapped to the specified function tag. + * @param tag the function tag + * @return the set of KeyBindings + */ + public final Set getKeyBindingsFor(FunctionTag tag) { + return collectKeyBindings(null, tag); + } + + Set collectKeyBindings(Set bindings, FunctionTag tag) { + if (bindings == null) { + bindings = new HashSet<>(); + } + for (Map.Entry en : map.entrySet()) { + if (en.getKey() instanceof KeyBinding k) { + if ((tag == null) || (tag == en.getValue())) { + bindings.add(k); + } + } + } + return bindings; + } + + /** + * This convenience method registers a copy of the behavior-specific mappings from one key binding to another. + * The method does nothing if no behavior specific mapping can be found. + * @param existing the existing key binding + * @param newk the new key binding + */ + public final void duplicateMapping(KeyBinding existing, KeyBinding newk) { + Object x = map.get(existing); + if (x != null) { + map.put(newk, x); + } + } + + final boolean execute(Object source, FunctionTag tag) { + Object x = map.get(tag); + if (x instanceof Runnable r) { + r.run(); + return true; + } else if (x instanceof FunctionHandler f) { + return f.handleFunction(); + } else if (x instanceof Stateless.FHandler h) { + h.handleFunction(source); + return true; + } else if (x instanceof Stateless.FHandlerConditional h) { + return h.handleFunction(source); + } + return false; + } + + void unbind(FunctionTag tag) { + Iterator> it = map.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry en = it.next(); + if (tag == en.getValue()) { + // the entry must be KeyBinding -> FunctionTag + it.remove(); + } + } + } + + void forEach(TriConsumer client) { + for (Map.Entry en : map.entrySet()) { + if (en.getKey() instanceof EventType type) { + PHList hs = (PHList)en.getValue(); + hs.forEach((pri, h) -> { + client.accept(type, pri, h); + return true; + }); + } + } + } + + @FunctionalInterface + static interface TriConsumer { + public void accept(EventType type, EventHandlerPriority pri, EventHandler h); + } + + /** + * Creates the stateful SkinInputMap. + * @return the stateful SkinInputMap + */ + public static SkinInputMap.Stateful create() { + return new Stateful(); + } + + /** + * Creates the stateless SkinInputMap. + * @param the type of Control + * @return the stateless SkinInputMap + */ + public static SkinInputMap.Stateless createStateless() { + return new Stateless(); + } + + /** SkinInputMap for skins that maintain stateful behaviors */ + public static final class Stateful extends SkinInputMap { + Stateful() { + } + + /** + * Maps a function to the specified function tag. + * + * @param tag the function tag + * @param function the function + */ + public final void registerFunction(FunctionTag tag, Runnable function) { + map.put(tag, function); + } + + /** + * Maps a function to the specified function tag. + * This method allows for controlling whether the matching event will be consumed or not. + * + * @param tag the function tag + * @param function the function + */ + public final void registerFunction(FunctionTag tag, FunctionHandler function) { + map.put(tag, function); + } + + /** + * This convenience method maps the function tag to the specified function, and at the same time + * maps the specified key binding to that function tag. + * @param tag the function tag + * @param k the key binding + * @param func the function + */ + public final void register(FunctionTag tag, KeyBinding k, Runnable func) { + registerFunction(tag, func); + registerKey(k, tag); + } + + /** + * This convenience method maps the function tag to the specified function, and at the same time + * maps the specified key binding to that function tag. + * @param tag the function tag + * @param code the key code + * @param func the function + */ + public final void register(FunctionTag tag, KeyCode code, Runnable func) { + registerFunction(tag, func); + registerKey(KeyBinding.of(code), tag); + } + } + + /** + * SkinInputMap for skins that either encapsulate the state fully in their Controls, + * or don't require a state at all. + * + * @param the type of Control + */ + // NOTE: The stateless skin input map adds significant complexity to the API surface while providing + // limited (some say non-existent) savings in terms of memory. There aren't many Controls that + // have a stateless behavior, which further reduces the usefulness of this class. + // I'd rather remove this feature altogether. + public static final class Stateless extends SkinInputMap { + /** + * The function handler that always consumes the corresponding event. + * @param the type of Control + */ + public interface FHandler { + /** + * The function mapped to a key binding. + * @param control the instance of Control + */ + public void handleFunction(C control); + } + + /** + * The function handler that allows to control whether the corresponding event will get consumed. + * @param the type of Control + */ + public interface FHandlerConditional { + /** + * The function mapped to a key binding. The return value instructs the owning InputMap + * to consume the triggering event or not. + * @param control the instance of Control + * @return true to consume the event, false otherwise + */ + public boolean handleFunction(C control); + } + + Stateless() { + } + + /** + * Maps a function to the specified function tag. + * + * @param tag the function tag + * @param function the function + */ + public final void registerFunction(FunctionTag tag, FHandler function) { + map.put(tag, function); + } + + /** + * Maps a function to the specified function tag. + * This method allows for controlling whether the matching event will be consumed or not. + * + * @param tag the function tag + * @param function the function + */ + public final void registerFunction(FunctionTag tag, FHandlerConditional function) { + map.put(tag, function); + } + + /** + * This convenience method maps the function tag to the specified function, and at the same time + * maps the specified key binding to that function tag. + * @param tag the function tag + * @param k the key binding + * @param func the function + */ + public final void register(FunctionTag tag, KeyBinding k, FHandler func) { + registerFunction(tag, func); + registerKey(k, tag); + } + + /** + * This convenience method maps the function tag to the specified function, and at the same time + * maps the specified key binding to that function tag. + * @param tag the function tag + * @param code the key code + * @param func the function + */ + public final void register(FunctionTag tag, KeyCode code, FHandler func) { + registerFunction(tag, func); + registerKey(KeyBinding.of(code), tag); + } + } +} diff --git a/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/package-info.java b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/package-info.java new file mode 100644 index 00000000000..85914e16bf0 --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/jfx/incubator/scene/control/input/package-info.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/** + *

The jfx.incubator.scene.control.input package contains classes related + * to the handling of the input events by the Control: input maps, key bindings, and related classes. + *

+ * See + * Public InputMap Proposal + * for more info. + *

+ *
Incubating Feature. + * Will be removed in a future release. + * @since 999 TODO + */ +package jfx.incubator.scene.control.input; diff --git a/modules/jfx.incubator.input/src/main/java/module-info.java b/modules/jfx.incubator.input/src/main/java/module-info.java new file mode 100644 index 00000000000..02bb44983ab --- /dev/null +++ b/modules/jfx.incubator.input/src/main/java/module-info.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/** + * InputMap (Incubator) + * + *
Incubating Feature. + * Will be removed in a future release. + * + * @moduleGraph + * @since 999 + */ +module jfx.incubator.input { + requires transitive javafx.base; + requires transitive javafx.graphics; + requires transitive javafx.controls; + + exports jfx.incubator.scene.control.input; + + // becomes unnecessary once InputMap is moved to Control moved to Control JDK-8314968 + exports com.sun.jfx.incubator.scene.control.input to jfx.incubator.richtext; +} diff --git a/modules/jfx.incubator.input/src/test/addExports b/modules/jfx.incubator.input/src/test/addExports new file mode 100644 index 00000000000..fa23f1e7876 --- /dev/null +++ b/modules/jfx.incubator.input/src/test/addExports @@ -0,0 +1,20 @@ +--add-exports javafx.base/com.sun.javafx=ALL-UNNAMED +# +--add-exports javafx.graphics/com.sun.javafx.application=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.perf=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.stage=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.util=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.prism=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.scenario.animation=ALL-UNNAMED +# +--add-exports javafx.controls/com.sun.javafx.scene.control.behavior=ALL-UNNAMED +--add-exports javafx.controls/com.sun.javafx.scene.control.inputmap=ALL-UNNAMED +--add-exports javafx.controls/com.sun.javafx.scene.control.skin=ALL-UNNAMED +--add-exports javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED +# +--add-exports jfx.incubator.richtext/com.sun.jfx.incubator.scene.control.dummy=ALL-UNNAMED +--add-exports jfx.incubator.richtext/com.sun.jfx.incubator.scene.control.rich=ALL-UNNAMED diff --git a/modules/jfx.incubator.richtext/.classpath b/modules/jfx.incubator.richtext/.classpath new file mode 100644 index 00000000000..037bbf3fcd7 --- /dev/null +++ b/modules/jfx.incubator.richtext/.classpath @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/jfx.incubator.richtext/.project b/modules/jfx.incubator.richtext/.project new file mode 100644 index 00000000000..6aa258953f1 --- /dev/null +++ b/modules/jfx.incubator.richtext/.project @@ -0,0 +1,17 @@ + + + incubator.richtext + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/modules/jfx.incubator.richtext/.settings/org.eclipse.core.resources.prefs b/modules/jfx.incubator.richtext/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 00000000000..99f26c0203a --- /dev/null +++ b/modules/jfx.incubator.richtext/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/modules/jfx.incubator.richtext/README.md b/modules/jfx.incubator.richtext/README.md new file mode 100644 index 00000000000..06d5d3f8b7d --- /dev/null +++ b/modules/jfx.incubator.richtext/README.md @@ -0,0 +1,12 @@ +# Incubator + +This project incubates +[RichTextArea](src/main/java/javafx/incubator/scene/control/rich/RichTextArea.java) +and +[CodeArea](src/main/java/javafx/incubator/scene/control/rich/code/CodeArea.java) +controls. + + +## Demo Projects + +Please refer to this [README](/tests/manual/RichTextAreaDemo/README.md). diff --git a/modules/jfx.incubator.richtext/src/main/docs/jfx/incubator/scene/control/rich/doc-files/CodeArea.png b/modules/jfx.incubator.richtext/src/main/docs/jfx/incubator/scene/control/rich/doc-files/CodeArea.png new file mode 100644 index 00000000000..204f6bfaac8 Binary files /dev/null and b/modules/jfx.incubator.richtext/src/main/docs/jfx/incubator/scene/control/rich/doc-files/CodeArea.png differ diff --git a/modules/jfx.incubator.richtext/src/main/docs/jfx/incubator/scene/control/rich/doc-files/RichTextArea.png b/modules/jfx.incubator.richtext/src/main/docs/jfx/incubator/scene/control/rich/doc-files/RichTextArea.png new file mode 100644 index 00000000000..d1fe81723af Binary files /dev/null and b/modules/jfx.incubator.richtext/src/main/docs/jfx/incubator/scene/control/rich/doc-files/RichTextArea.png differ diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CachingStyleResolver.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CachingStyleResolver.java new file mode 100644 index 00000000000..289bba1508e --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CachingStyleResolver.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.HashMap; +import javafx.scene.Node; +import javafx.scene.image.WritableImage; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Caching StyleResolver caches conversion results to avoid re-querying for the same information. + */ +public class CachingStyleResolver implements StyleResolver { + private final StyleResolver resolver; + private final HashMap cache = new HashMap<>(); + + public CachingStyleResolver(StyleResolver r) { + this.resolver = r; + } + + @Override + public StyleAttrs resolveStyles(StyleAttrs attrs) { + CssStyles css = attrs.get(CssStyles.CSS); + if (css == null) { + // no conversion is needed + return attrs; + } + + StyleAttrs a = cache.get(css); + if (a == null) { + a = resolver.resolveStyles(attrs); + cache.put(css, a); + } + return a; + } + + @Override + public WritableImage snapshot(Node node) { + return resolver.snapshot(node); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CaretInfo.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CaretInfo.java new file mode 100644 index 00000000000..c00fdb9af41 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CaretInfo.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.Objects; +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.PathElement; + +/** + * Captures the caret position and bounds in the content view coordinates. + */ +public final class CaretInfo { + private final double xmin; + private final double xmax; + private final double ymin; + private final double ymax; + private final double lineSpacing; + private final PathElement[] path; + + private CaretInfo(double xmin, double xmax, double ymin, double ymax, double lineSpacing, PathElement[] path) { + this.xmin = xmin; + this.xmax = xmax; + this.ymin = ymin; + this.ymax = ymax; + this.lineSpacing = lineSpacing; + this.path = path; + } + + /** + * Creates an instance of CaretInfo given the path and translation offsets required to + * convert path coordinates (which come in the frame of reference of its {@code TextFlow}) to the view port + * coordinates. + * + * @param lineSpacing the line spacing + * @param path the caret path + * @return the CaretInfo instance + */ + public static CaretInfo create(double lineSpacing, PathElement[] path) { + Objects.requireNonNull(path); + if (path.length == 0) { + throw new IllegalArgumentException("non-empty path is required"); + } + + double xmin = Double.POSITIVE_INFINITY; + double xmax = Double.NEGATIVE_INFINITY; + double ymin = Double.POSITIVE_INFINITY; + double ymax = Double.NEGATIVE_INFINITY; + + int sz = path.length; + for (int i = 0; i < sz; i++) { + PathElement em = path[i]; + if (em instanceof LineTo lineto) { + double x = lineto.getX(); + double y = lineto.getY(); + + x = halfPixel(x); + if (x < xmin) { + xmin = x; + } else if (x > xmax) { + xmax = x; + } + + y = halfPixel(y); + if (y < ymin) { + ymin = y; + } else if (y > ymax) { + ymax = y; + } + } else if (em instanceof MoveTo moveto) { + double x = moveto.getX(); + double y = moveto.getY(); + + x = halfPixel(x); + if (x < xmin) { + xmin = x; + } else if (x > xmax) { + xmax = x; + } + + y = halfPixel(y); + if (y < ymin) { + ymin = y; + } else if (y > ymax) { + ymax = y; + } + } else { + throw new IllegalArgumentException("Unexpected PathElement: " + em); + } + } + + return new CaretInfo(xmin, xmax, ymin, ymax, lineSpacing, path); + } + + /** + * Returns the smallest x coordinate of the caret shape bounding box. + * @return minimum x coordinate + */ + public final double getMinX() { + return xmin; + } + + /** + * Returns the largest x coordinate of the caret shape bounding box. + * @return maximum x coordinate + */ + public final double getMaxX() { + return xmax; + } + + /** + * Returns the smallest y coordinate of the caret shape bounding box. + * @return minimum y coordinate + */ + public final double getMinY() { + return ymin; + } + + /** + * Returns the largest y coordinate of the caret shape bounding box. + * @return maximum y coordinate + */ + public final double getMaxY() { + return ymax; + } + + /** + * Returns the line spacing at the caret position. + * @return the line spacing + */ + public final double getLineSpacing() { + return lineSpacing; + } + + /** + * Returns the caret path. + * @return the non-null array of path elements + */ + public final PathElement[] path() { + return path; + } + + /** + * Returns true if the specified y coordinate is between the smallest and largest y coordinate of the + * caret bounding box. + * + * @param y the Y coordinate + * @return true if the coordinate is within the caret bounding box + */ + public final boolean containsY(double y) { + return (y >= ymin) && (y < ymax); + } + + private static double halfPixel(double coord) { + return Math.round(coord + 0.5) - 0.5; + } + + @Override + public String toString() { + return "CaretInfo{xmin=" + xmin + ", xmax=" + xmax + ", ymin=" + ymin + ", ymax=" + ymax + "}"; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CellArrangement.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CellArrangement.java new file mode 100644 index 00000000000..e6a34b8456b --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CellArrangement.java @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.ArrayList; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.shape.PathElement; +import javafx.scene.text.HitInfo; +import javafx.scene.text.TextFlow; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Manages TextCells in a sliding window, comprised of the visible area and some number of screenfuls + * before and after the visible area, for the purposes of layout. + * The purpose is to estimating the average paragraph height, and to support relative navigation. + */ +public class CellArrangement { + private final ArrayList cells = new ArrayList<>(32); + private final double flowWidth; + private final double flowHeight; + private final int lineCount; + private final Insets contentPadding; + private final Origin origin; + private int visibleCount; + private int bottomCount; + private double unwrappedWidth; + private double topHeight; + private double bottomHeight; + private double totalHeight; + private Node[] left; + private Node[] right; + + public CellArrangement(VFlow f) { + this.flowWidth = f.getWidth(); + this.flowHeight = f.getViewHeight(); + this.origin = f.getOrigin(); + this.lineCount = f.getParagraphCount(); + this.contentPadding = f.contentPadding(); + } + + // TODO not called right now, use it to skip reflow when not necessary + public boolean isValid(VFlow f) { + return + (f.getWidth() == flowWidth) && + (f.getHeight() == flowHeight) && + (f.topCellIndex() == origin.index()) && + (RichUtils.equals(f.contentPadding(), contentPadding)); + } + + @Override + public String toString() { + return + "CellArrangement{" + + origin + + ", topCount=" + topCount() + + ", visible=" + getVisibleCellCount() + + ", bottomCount=" + bottomCount + + ", topHeight=" + topHeight + + ", bottomHeight=" + bottomHeight + + ", lineCount=" + lineCount + + ", average=" + averageHeight() + + ", estMax=" + estimatedMax() + + ", unwrapped=" + getUnwrappedWidth() + + "}"; + } + + public void addCell(TextCell cell) { + cells.add(cell); + } + + public void setUnwrappedWidth(double w) { + unwrappedWidth = w; + } + + /** returns snapped(ceil) size */ + public double getUnwrappedWidth() { + return unwrappedWidth; + } + + public int getVisibleCellCount() { + return visibleCount; + } + + public void setVisibleCellCount(int n) { + visibleCount = n; + } + + /** finds text position inside the sliding window, in cell coordinates */ + public TextPos getTextPos(double cellX, double cellY) { + if (lineCount == 0) { + return TextPos.ZERO; + } + + int topIx = topIndex(); + int btmIx = bottomIndex(); + + int ix = binarySearch(cellY, topIx, btmIx - 1); + TextCell cell = getCell(ix); + if (cell != null) { + Region r = cell.getContent(); + Insets pad = r.getPadding(); + double y = cellY - cell.getY() - pad.getTop(); + if (y < 0) { + return new TextPos(cell.getIndex(), 0, 0, true); + } else if (y < cell.getCellHeight()) { + if (r instanceof TextFlow t) { + double x = cellX - pad.getLeft(); + Point2D p = new Point2D(x - r.getLayoutX(), y - r.getLayoutY()); + HitInfo h = t.hitTest(p); + int ii = h.getInsertionIndex(); + int ci = h.getCharIndex(); + boolean leading = h.isLeading(); + //System.out.println("CellArrangmenet.getTextPos ix=" + ii + " ci=" + ci + " leading=" + leading); // FIX + return new TextPos(cell.getIndex(), ii, ci, leading); + } else { + return new TextPos(cell.getIndex(), 0, 0, true); + } + } + + int cix = 0; + if (r instanceof TextFlow f) { + cix = RichUtils.getTextLength(f); + } + return new TextPos(cell.getIndex(), cix, cix, true); + } + + return TextPos.ZERO; + } + + /** returns the cell contained in this layout, or null */ + public TextCell getCell(int modelIndex) { + int ix = modelIndex - origin.index(); + if (ix < 0) { + if ((ix + topCount()) >= 0) { + // cells in the top part come after bottom part, and in reverse order + return cells.get(bottomCount - ix - 1); + } + } else if (ix < bottomCount) { + // cells in the normal (bottom) part + return cells.get(ix); + } + return null; + } + + /** returns a visible cell, or null */ + public TextCell getVisibleCell(int modelIndex) { + int ix = modelIndex - origin.index(); + if ((ix >= 0) && (ix < visibleCount)) { + return cells.get(ix); + } + return null; + } + + /** returns a TextCell from the visible or bottom margin parts, or null */ + public TextCell getCellAt(int ix) { + if (ix < visibleCount) { + return cells.get(ix); + } + return null; + } + + public CaretInfo getCaretInfo(Region target, double xoffset, TextPos p) { + if (p != null) { + int ix = p.index(); + TextCell cell = getCell(ix); + if (cell != null) { + int charIndex = p.charIndex(); + boolean leading = p.isLeading(); + double dx = -contentPadding.getLeft(); + PathElement[] path = cell.getCaretShape(target, charIndex, leading, dx, 0.0); + if (path == null) { + return null; + } + + double lineSpacing = cell.getLineSpacing(); + return CaretInfo.create(lineSpacing, path); + } + } + return null; + } + + public void removeNodesFrom(Pane p) { + ObservableList cs = p.getChildren(); + for (int i = getVisibleCellCount() - 1; i >= 0; --i) { + TextCell cell = cells.get(i); + cs.remove(cell); + } + } + + public void setBottomCount(int ix) { + bottomCount = ix; + } + + public int bottomCount() { + return bottomCount; + } + + public void setBottomHeight(double h) { + bottomHeight = h; + } + + public double bottomHeight() { + return bottomHeight; + } + + public int topCount() { + return cells.size() - bottomCount; + } + + public void setTopHeight(double h) { + topHeight = h; + } + + public double topHeight() { + return topHeight; + } + + public double averageHeight() { + return (topHeight + bottomHeight) / (topCount() + bottomCount); + } + + public double estimatedMax() { + return (lineCount - topCount() - bottomCount) * averageHeight() + topHeight + bottomHeight; + } + + /** + * finds a model index of a cell that contains the given localY. + * (in vflow frame of reference). + * Should not be called with localY outside of this layout sliding window. + */ + private int binarySearch(double localY, int low, int high) { + //System.err.println(" binarySearch off=" + off + ", high=" + high + ", low=" + low); // FIX + while (low <= high) { + // TODO might be a problem for 2B-rows models + int mid = (low + high) >>> 1; + TextCell cell = getCell(mid); + int cmp = compare(cell, localY); + if (cmp < 0) { + low = mid + 1; + } else if (cmp > 0) { + high = mid - 1; + } else { + return mid; + } + } + return low; + } + + private int compare(TextCell cell, double localY) { + double y = cell.getY(); + if (localY < y) { + return 1; + } else if (localY >= y + cell.getCellHeight()) { + if (cell.getIndex() == (lineCount - 1)) { + return 0; + } + return -1; + } + return 0; + } + + /** returns a model index of the first cell in the sliding window top margin */ + public int topIndex() { + return origin.index() - topCount(); + } + + /** returns a model index of the last cell in the sliding window bottom margin + 1 */ + public int bottomIndex() { + return origin.index() + bottomCount; + } + + /** creates a new Origin from the absolute position [0.0 ... (1.0-normalized.visible.amount)] */ + public Origin fromAbsolutePosition(double pos) { + int topIx = topIndex(); + int btmIx = bottomIndex(); + int ix = (int)(pos * lineCount); + if ((ix >= topIx) && (ix < btmIx)) { + // inside the layout + double top = topIx / (double)lineCount; + double btm = btmIx / (double)lineCount; + double f = (pos - top) / (btm - top); // TODO check for dvi0/infinity/NaN + double localY = f * (topHeight + bottomHeight) - topHeight; + + ix = binarySearch(localY, topIx, btmIx - 1); + TextCell cell = getCell(ix); + return new Origin(cell.getIndex(), localY - cell.getY()); + } + return new Origin(ix, 0.0); + } + + public Origin computeOrigin(double delta) { + int topIx = topIndex(); + int btmIx = bottomIndex(); + double y = delta; + + if (delta < 0) { + // do not scroll above the top edge + double top = -origin.offset() - topHeight; + if (y < top) { + if (topIx == 0) { + double topPadding = contentPadding.getTop(); + y = Math.max(y, -topPadding); + return new Origin(0, y); + } + return new Origin(topIx, 0.0); + } + } else { + // do not scroll past (bottom edge - visible area) + double max = bottomHeight - flowHeight; + if (max < 0) { + return null; + } + if (y > max) { + y = max; + } + } + + int ix = binarySearch(y, topIx, btmIx - 1); + TextCell cell = getCell(ix); + double off = y - cell.getY(); + return new Origin(cell.getIndex(), off); + } + + public void addLeftNode(int index, Node n) { + if (left == null) { + left = new Node[visibleCount]; + } + left[index] = n; + } + + public void addRightNode(int index, Node n) { + if (right == null) { + right = new Node[visibleCount]; + } + right[index] = n; + } + + public Node getLeftNodeAt(int index) { + return left[index]; + } + + public Node getRightNodeAt(int index) { + return right[index]; + } + + /** + * Returns the total height of all cells that intersect the viewport, or Double.POSITIVE_INFINITY if there + * are at least one cell lays beyond the viewport. + * @return the total width + */ + public double getTotalHeight() { + return totalHeight; + } + + void setTotalHeight(double h) { + totalHeight = h; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/ClippedPane.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/ClippedPane.java new file mode 100644 index 00000000000..aa5f27a1e6f --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/ClippedPane.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.Node; +import javafx.scene.layout.Pane; +import javafx.scene.shape.Rectangle; + +/** + * Pane that allows for container/controller to lay out its children, + * clipping its content to its bounds. + */ +public class ClippedPane extends Pane { + private final Rectangle clip; + + public ClippedPane(String cssName) { + getStyleClass().add(cssName); + + clip = new Rectangle(); + clip.widthProperty().bind(widthProperty()); + clip.heightProperty().bind(heightProperty()); + setClip(clip); + } + + public void layoutInArea(Node n, double x, double y, double w, double h) { + layoutInArea(n, x, y, w, h, 0.0, Insets.EMPTY, HPos.LEFT, VPos.TOP); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CompoundKey.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CompoundKey.java new file mode 100644 index 00000000000..8afbf53675d --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CompoundKey.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.Arrays; + +/** + * Compound Key + */ +public class CompoundKey { + private final Object[] keys; + + public CompoundKey(Object... keys) { + this.keys = keys; + } + + @Override + public int hashCode() { + int h = CompoundKey.class.hashCode(); + return 31 * h + Arrays.hashCode(keys); + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof CompoundKey c) { + return Arrays.equals(keys, c.keys); + } else { + return false; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Converters.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Converters.java new file mode 100644 index 00000000000..7313b2c8125 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Converters.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import javafx.util.StringConverter; +import jfx.incubator.scene.control.rich.model.ParagraphDirection; + +/** + * Converters used to serialize/deserialize text attributes. + */ +public class Converters { + public static StringConverter booleanConverter() { + return new StringConverter() { + @Override + public String toString(Boolean v) { + // do not output value of a boolean attribute + return null; + } + + @Override + public Boolean fromString(String s) { + // attribute present means it's value is TRUE + return Boolean.TRUE; + } + }; + } + + public static StringConverter colorConverter() { + return new StringConverter() { + @Override + public String toString(Color c) { + return toHexColor(c); + } + + @Override + public Color fromString(String s) { + return parseHexColor(s); + } + }; + } + + public static StringConverter paragraphDirectionConverter() { + return new StringConverter() { + @Override + public String toString(ParagraphDirection d) { + return fromParagraphDirection(d); + } + + @Override + public ParagraphDirection fromString(String s) { + return toParagraphDirection(s); + } + }; + } + + public static StringConverter textAlignmentConverter() { + return new StringConverter() { + @Override + public String toString(TextAlignment v) { + return fromTextAlignment(v); + } + + @Override + public TextAlignment fromString(String s) { + return toTextAlignment(s); + } + }; + } + + public static StringConverter stringConverter() { + return new StringConverter() { + @Override + public String toString(String x) { + return x; + } + + @Override + public String fromString(String s) { + return s; + } + }; + } + + private static Color parseHexColor(String s) { + double alpha; + switch(s.length()) { + case 8: + // rrggbbaa + alpha = parseByte(s, 6) / 255.0; + break; + case 6: + // rrggbb + alpha = 1.0; + break; + default: + throw new IllegalArgumentException("unable to parse color: " + s); + } + + int r = parseByte(s, 0); + int g = parseByte(s, 2); + int b = parseByte(s, 4); + return Color.rgb(r, g, b, alpha); + } + + protected static String toHexColor(Color c) { + return + toHex8(c.getRed()) + + toHex8(c.getGreen()) + + toHex8(c.getBlue()) + + ((c.getOpacity() == 1.0) ? "" : toHex8(c.getOpacity())); + } + + private static String toHex8(double x) { + int v = (int)Math.round(255.0 * x); + if (v < 0) { + v = 0; + } else if (v > 255) { + v = 255; + } + return String.format("%02X", v); + } + + protected static int parseByte(String text, int start) { + int v = parseHexChar(text.charAt(start)) << 4; + v += parseHexChar(text.charAt(start + 1)); + return v; + } + + private static int parseHexChar(int ch) { + int c = ch - '0'; // 0...9 + if ((c >= 0) && (c <= 9)) { + return c; + } + c = ch - 55; // handle A...F + if ((c >= 10) && (c <= 15)) { + return c; + } + c = ch - 97; // handle a...f + if ((c >= 10) && (c <= 15)) { + return c; + } + throw new IllegalArgumentException("not a hex char:" + ch); + } + + private static String fromTextAlignment(TextAlignment a) { + switch (a) { + case CENTER: + return "C"; + case JUSTIFY: + return "J"; + case RIGHT: + return "R"; + case LEFT: + default: + return "L"; + } + } + + private static TextAlignment toTextAlignment(String s) { + switch (s) { + case "C": + return TextAlignment.CENTER; + case "J": + return TextAlignment.JUSTIFY; + case "L": + return TextAlignment.LEFT; + case "R": + return TextAlignment.RIGHT; + default: + throw new IllegalArgumentException("bad text alignment: " + s); + } + } + + private static String fromParagraphDirection(ParagraphDirection d) { + switch(d) { + case RIGHT_TO_LEFT: + return "R"; + case LEFT_TO_RIGHT: + default: + return "L"; + } + } + + private static ParagraphDirection toParagraphDirection(String s) { + switch(s) { + case "R": + return ParagraphDirection.RIGHT_TO_LEFT; + case "L": + default: + return ParagraphDirection.LEFT_TO_RIGHT; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CssStyles.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CssStyles.java new file mode 100644 index 00000000000..c418a9b03a6 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/CssStyles.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import jfx.incubator.scene.control.rich.model.StyleAttribute; + +/** + * Attribute represents CSS styles: a combination of a direct style (-fx-...) + * and a number of style names. + */ +public final record CssStyles(String style, String[] names) { + /** This special attribute contains CSS direct style and style names for text segments only */ + public static final StyleAttribute CSS = new StyleAttribute<>("CSS", CssStyles.class, false); +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FastCache.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FastCache.java new file mode 100644 index 00000000000..8a388674843 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FastCache.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Random; + +/** + * A simple cache implementation which provides a cheap invalidation via {@link #clear()} + * and a cheap random eviction via {@link #evict()}. + * This object must be accessed from the FX application thread, although it does not check. + */ +public class FastCache { + private static record Entry(int index, V cell) { } + + private int size; + private final Entry[] linear; + private final HashMap data; + private final static Random random = new Random(); + + public FastCache(int capacity) { + linear = new Entry[capacity]; + data = new HashMap<>(capacity); + } + + public T get(int row) { + return data.get(row); + } + + /** + * Adds a new cell to the cache. When the cache is full, this method evicts a + * random cell from the cache first. NOTE: this method does not check whether + * another cell for the given row is present, so this call must be preceded by a + * {@link #get(int)}. + */ + public void add(int index, T cell) { + int ix; + if (size >= capacity()) { + ix = evict(); + } else { + ix = size++; + } + + data.put(index, cell); + linear[ix] = new Entry<>(index, cell); + } + + /** returns an index in the linear array of the cell that has been evicted */ + protected int evict() { + int ix = random.nextInt(size); + // does not clear the slot because it will get overwritten by the caller + Entry en = linear[ix]; + int index = en.index(); + data.remove(index); + return ix; + } + + public int size() { + return size; + } + + public int capacity() { + return linear.length; + } + + public void clear() { + size = 0; + Arrays.fill(linear, null); + data.clear(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FirstLineIndentSpacer.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FirstLineIndentSpacer.java new file mode 100644 index 00000000000..65d3f39bb2a --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FirstLineIndentSpacer.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; + +/** + * A spacer node used to emulate the first line indent. + * + * FIX problems: + * - selection: TextFlow thinks there is a separate node (click on left side, move to right side of this node) + */ +public class FirstLineIndentSpacer extends Rectangle { + public FirstLineIndentSpacer(double width) { + super(width, 1); + setFill(Color.rgb(0, 0, 0, 0.0)); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FxPathBuilder.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FxPathBuilder.java new file mode 100644 index 00000000000..03cc385f809 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/FxPathBuilder.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.ArrayList; +import java.util.List; +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.PathElement; + +/** + * Conventient utility for building javafx {@link Path} + */ +public class FxPathBuilder { + private final ArrayList elements = new ArrayList<>(); + + public FxPathBuilder() { + } + + public void add(PathElement em) { + elements.add(em); + } + + public void addAll(PathElement... es) { + for (PathElement em : es) { + elements.add(em); + } + } + + public void moveto(double x, double y) { + add(new MoveTo(x, y)); + } + + public void lineto(double x, double y) { + add(new LineTo(x, y)); + } + + public List getPathElements() { + return elements; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/HighlightShape.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/HighlightShape.java new file mode 100644 index 00000000000..2a9ed6d3782 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/HighlightShape.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.ArrayList; +import java.util.List; +import javafx.scene.Node; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.Path; +import javafx.scene.shape.PathElement; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; + +/** + * This component gets added to TextFlow to provide various types of highlight: + *

    + *
  1. text highlight
  2. + *
  3. squiggly line
  4. + *
  5. underline
  6. + *
+ */ +public class HighlightShape extends Path { + public enum Type { + HIGHLIGHT, + SQUIGGLY, + } + + private final Type type; + private final int start; + private final int end; + + public HighlightShape(Type t, int start, int end) { + this.type = t; + this.start = start; + this.end = end; + } + + private PathElement[] createPath(TextFlow f) { + switch (type) { + case HIGHLIGHT: + return f.rangeShape(start, end); + case SQUIGGLY: + PathElement[] pe = f.underlineShape(start, end); + return generateSquiggly(pe); + default: + return f.underlineShape(start, end); + } + } + + // underlineShape returns a series of rectangular shapes (MLLLL,MLLLL,...) + // first we convert each rectangle to a line at its vertical midpoint, + // then generate squiggly line (saw tooth, actually, for now) + private PathElement[] generateSquiggly(PathElement[] in) { + ArrayList list = new ArrayList<>(in.length * 8); + double x0 = Integer.MAX_VALUE; + double x1 = Integer.MIN_VALUE; + double y0 = Integer.MAX_VALUE; + double y1 = Integer.MIN_VALUE; + int sz = in.length + 1; + + for (int i = 0; i < sz; i++) { + PathElement p = i < in.length ? in[i] : null; + + if ((p == null) || (p instanceof MoveTo)) { + if (x0 < x1) { + generateSquiggly(list, x0, x1, (y0 + y1) / 2.0, 1.0); + } + + if (p == null) { + break; + } + + x0 = Integer.MAX_VALUE; + x1 = Integer.MIN_VALUE; + y0 = Integer.MAX_VALUE; + y1 = Integer.MIN_VALUE; + } + + if (p instanceof MoveTo mt) { + double x = mt.getX(); + if (x < x0) { + x0 = x; + } + if (x > x1) { + x1 = x; + } + double y = mt.getY(); + if (y < y0) { + y0 = y; + } + if (y > y1) { + y1 = y; + } + } else if (p instanceof LineTo lt) { + double x = lt.getX(); + if (x < x0) { + x0 = x; + } + if (x > x1) { + x1 = x; + } + double y = lt.getY(); + if (y < y0) { + y0 = y; + } + if (y > y1) { + y1 = y; + } + } + } + + return list.toArray(new PathElement[list.size()]); + } + + private void generateSquiggly(List list, double xmin, double xmax, double ycenter, double sz) { + double x = xmin; + double y = ycenter; + double y0 = ycenter - sz; + double y1 = ycenter + sz; + boolean up = true; + boolean run = true; + + list.add(new MoveTo(x, y)); + + while (run) { + double delta = up ? (y - y0) : (y1 - y); + if (x + delta > xmax) { + delta = xmax - x; + run = false; + } + + x += delta; + if (up) { + y -= delta; + } else { + y += delta; + } + up = !up; + + list.add(new LineTo(x, y)); + } + } + + private void updatePath(TextFlow f) { + PathElement[] pe = createPath(f); + getElements().setAll(pe); + } + + public static void addTo(Region r, Type t, int start, int end, Color c) { + if (r instanceof TextFlow f) { + String style = createStyle(t, c); + addHighlight(f, t, start, end, style, null); + } + } + + public static void addTo(Region r, Type t, int start, int end, String... styles) { + if (r instanceof TextFlow f) { + addHighlight(f, t, start, end, null, styles); + } + } + + private static String createStyle(Type t, Color c) { + switch (t) { + case HIGHLIGHT: + // filled shape + return "-fx-fill: " + RichUtils.toCssColor(c) + "; -fx-stroke-width:0;"; + default: + // stroke + return "-fx-stroke: " + RichUtils.toCssColor(c) + "; -fx-stroke-width:1;"; + } + } + + private static void addHighlight(TextFlow f, Type t, int start, int end, String directStyle, String[] styles) { + HighlightShape p = new HighlightShape(t, start, end); + p.setStyle(directStyle); + if (styles != null) { + p.getStyleClass().addAll(styles); + } + + f.widthProperty().addListener((x) -> p.updatePath(f)); + p.updatePath(f); + p.setManaged(false); + + // highlights must be added before any Text nodes + List children = f.getChildren(); + int sz = children.size(); + int ix = -1; + for (int i = 0; i < sz; i++) { + if (children.get(i) instanceof Text) { + ix = i; + break; + } + } + if (ix < 0) { + children.add(p); + } else { + children.add(ix, p); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/HtmlStyledOutput.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/HtmlStyledOutput.java new file mode 100644 index 00000000000..3e624ad4b57 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/HtmlStyledOutput.java @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import java.io.Writer; +import java.util.Base64; +import java.util.HashMap; +import javafx.scene.Node; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledOutput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +/** + * A {@link StyledOutput} which generates HTML output. + */ +// TODO should 'monospaced' paragraphs use
 ?
+// TODO should we size down font on windows?
+public class HtmlStyledOutput implements StyledOutput {
+    // a synthetic attribute used only in Key
+    private static final StyleAttribute SS_AND_UNDERLINE = new StyleAttribute<>("SS_AND_UNDERLINE", Key.class, false);
+    private final StyleResolver resolver;
+    private final Writer wr;
+    private final boolean inlineStyles;
+    private record Key(StyleAttribute attr, Object value) { }
+    private final HashMap styles = new HashMap<>();
+
+    public HtmlStyledOutput(StyleResolver resolver, Writer wr, boolean inlineStyles) {
+        this.resolver = resolver;
+        this.wr = wr;
+        this.inlineStyles = inlineStyles;
+    }
+
+    @Override
+    public void append(StyledSegment seg) throws IOException {
+        switch (seg.getType()) {
+        case INLINE_NODE:
+            Node n = seg.getInlineNodeGenerator().get();
+            writeInlineNode(n);
+            break;
+        case LINE_BREAK:
+            // TODO perhaps use a boolean flag to emit separate p and /p tags
+            wr.write("

\n"); + break; + case TEXT: + StyleAttrs a = seg.getStyleAttrs(resolver); + boolean div = ((a != null) && (!a.isEmpty())); + if (div) { + wr.write(""); + } + String text = seg.getText(); + String encoded = encode(text); + wr.write(encoded); + if (div) { + wr.write(""); + } + break; + case REGION: + Region r = seg.getParagraphNodeGenerator().get(); + writeParagraph(r); + break; + } + } + + private void writeAttributes(StyleAttrs attrs) throws IOException { + boolean sp = false; + for (StyleAttribute a : attrs.getAttributes()) { + Object v = attrs.get(a); + if (v != null) { + Key k = createKey(attrs, a, v); + if (k != null) { + if (sp) { + wr.write(' '); + } else { + sp = true; + } + + Val val = styles.get(k); + if (inlineStyles) { + wr.write(val.css); + } else { + wr.write(val.name); + } + } + } + } + } + + /** + * Special handing is required since STRIKE_THROUGH and UNDERLINE are mapped to + * the same text-decoration CSS property. + * @returns the new key or null if this attribute must be skipped. + */ + private static Key createKey(StyleAttrs attrs, StyleAttribute a, Object v) { + if (a == StyleAttrs.STRIKE_THROUGH) { + if (attrs.isStrikeThrough() && attrs.isUnderline()) { + a = SS_AND_UNDERLINE; + } + } else if (a == StyleAttrs.UNDERLINE) { + if (attrs.isStrikeThrough() && attrs.isUnderline()) { + return null; + } + } + return new Key(a, v); + } + + private void writeParagraph(Region n) throws IOException { + WritableImage im = resolver.snapshot(n); + int w = (int)im.getWidth(); + int h = (int)im.getHeight(); + byte[] b = RichUtils.writePNG(im); + String base64 = Base64.getEncoder().encodeToString(b); + wr.write("

"); + } + + private void writeInlineNode(Node n) throws IOException { + WritableImage im = resolver.snapshot(n); + int w = (int)im.getWidth(); + int h = (int)im.getHeight(); + byte[] b = RichUtils.writePNG(im); + String base64 = Base64.getEncoder().encodeToString(b); + wr.write(""); + } + + // TODO unit test! + private static String encode(String text) { + if (text == null) { + return ""; + } + + int ix = indexOfSpecialChar(text); + if (ix < 0) { + return text; + } + + int len = text.length(); + StringBuilder sb = new StringBuilder(len + 32); + sb.append(text, 0, ix); + + for (int i = ix; i < len; i++) { + char c = text.charAt(i); + if (c < 0x20) { + switch (c) { + case '\t': + sb.append("
\t
"); + break; + default: + sb.append("&#"); + sb.append(nibbleChar(c >> 4)); + sb.append(nibbleChar(c)); + sb.append(';'); + } + } else { + switch (c) { + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '"': + sb.append("""); + break; + case '\'': + sb.append("'"); + break; + case '&': + sb.append("&"); + break; + default: + sb.append(c); + break; + } + } + } + + return sb.toString(); + } + + private static int indexOfSpecialChar(String text) { + int len = text.length(); + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + if (c < 0x20) { + return i; + } else { + switch (c) { + case '<': + case '>': + case '"': + case '\'': + case '&': + return i; + default: + continue; + } + } + } + return -1; + } + + private static char nibbleChar(int x) { + return "0123456789abcdef".charAt(x & 0x0f); + } + + @Override + public void flush() throws IOException { + wr.flush(); + } + + @Override + public void close() throws IOException { + wr.close(); + } + + private static class Val { + public final String name; + public final String css; + + public Val(String name, String css) { + this.name = name; + this.css = css; + } + } + + public StyledOutput firstPassBuilder() { + return new StyledOutput() { + @Override + public void append(StyledSegment seg) throws IOException { + switch (seg.getType()) { + case TEXT: + StyleAttrs attrs = seg.getStyleAttrs(resolver); + if ((attrs != null) && (!attrs.isEmpty())) { + for (StyleAttribute a : attrs.getAttributes()) { + Object v = attrs.get(a); + if (v != null) { + Key k = createKey(attrs, a, v); + if (k != null) { + if (!styles.containsKey(k)) { + String css = createCss(k.attr, v); + if (css != null) { + String name = ".S" + styles.size(); + Val val = new Val(name, css); + styles.put(k, val); + } + } + } + } + } + } + break; + case PARAGRAPH_ATTRIBUTES: + // TODO + break; + } + } + + @Override + public void flush() throws IOException { + } + + @Override + public void close() throws IOException { + } + }; + } + + private static String createCss(StyleAttribute a, Object v) { + if (a == StyleAttrs.BOLD) { + return "font-weight: bold;"; + } else if (a == StyleAttrs.FONT_FAMILY) { + return "font-family: \"" + encodeFontFamily(v.toString()) + "\";"; + } else if (a == StyleAttrs.FONT_SIZE) { + return "font-size: " + v + "pt;"; + } else if (a == StyleAttrs.ITALIC) { + return "font-style: italic;"; + } else if (a == StyleAttrs.STRIKE_THROUGH) { + return "text-decoration: line-through;"; + } else if (a == StyleAttrs.TEXT_COLOR) { + return "color: " + RichUtils.toWebColor((Color)v) + ";"; + } else if (a == StyleAttrs.UNDERLINE) { + return "text-decoration: underline;"; + } else if (a == SS_AND_UNDERLINE) { + return "text-decoration: line-through underline;"; + } else { + return null; + } + } + + private static String encodeFontFamily(String name) { + switch (name.toLowerCase()) { + case "monospaced": + return "monospace"; + case "system": + case "sans-serif": + return "sans-serif"; + case "serif": + return "serif"; + case "cursive": + return "cursive"; + case "fantasy": + return "fantasy"; + } + return encode(name); + } + + public void writePrologue() throws IOException { + wr.write(""); + if (!inlineStyles) { + wr.write("\n"); + } + wr.write("\n"); + } + + public void writeEpilogue() throws IOException { + wr.write("\n\n"); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/MarkerHelper.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/MarkerHelper.java new file mode 100644 index 00000000000..312790a69a5 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/MarkerHelper.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import com.sun.javafx.util.Utils; +import jfx.incubator.scene.control.rich.Marker; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Manages Marker Accessor. + */ +public class MarkerHelper { + public interface Accessor { + public Marker createMarker(TextPos p); + public void setMarkerPos(Marker m, TextPos p); + } + + static { + Utils.forceInit(Marker.class); + } + + private static MarkerHelper.Accessor accessor; + + public static void setAccessor(MarkerHelper.Accessor a) { + if (accessor != null) { + throw new IllegalStateException(); + } + accessor = a; + } + + public static void setMarkerPos(Marker m, TextPos p) { + accessor.setMarkerPos(m, p); + } + + public static Marker createMarker(TextPos p) { + return accessor.createMarker(p); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Markers.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Markers.java new file mode 100644 index 00000000000..f3dfdb1efbd --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Markers.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor +package com.sun.jfx.incubator.scene.control.rich; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import jfx.incubator.scene.control.rich.Marker; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Manages Markers. + */ +public class Markers { + private HashMap>> markers; + + public Markers() { + markers = new HashMap<>(); + } + + public Marker getMarker(TextPos pos) { + List> refs = markers.get(pos); + if (refs != null) { + for (int i = 0; i < refs.size(); i++) { + WeakReference ref = refs.get(i); + Marker m = ref.get(); + if (m != null) { + return m; + } + } + } + + Marker m = MarkerHelper.createMarker(pos); + if (refs == null) { + refs = new ArrayList<>(2); + } + refs.add(new WeakReference<>(m)); + markers.put(pos, refs); + return m; + } + + @Override + public String toString() { + ArrayList list = new ArrayList<>(markers.keySet()); + Collections.sort(list); + + StringBuilder sb = new StringBuilder(); + sb.append('['); + boolean sep = false; + int sz = list.size(); + for (int i = 0; i < sz; i++) { + TextPos p = list.get(i); + if (sep) { + sb.append(','); + } else { + sep = true; + } + sb.append('{'); + sb.append(p.index()); + sb.append(','); + sb.append(p.offset()); + sb.append('}'); + } + sb.append(']'); + return sb.toString(); + } + + // TODO unit test + // TODO do we need (leading/trailing) bias in TextPos? + public void update(TextPos start, TextPos end, int charsTop, int linesAdded, int charsBottom) { +// System.out.println( +// "start=" + start + +// " end=" + end + +// " top=" + charsTop + +// " lines=" + linesAdded + +// " btm=" + charsBottom +// ); // FIX + if (start.compareTo(end) > 0) { + TextPos p = start; + start = end; + end = p; + } + + HashMap>> m2 = new HashMap<>(markers.size()); + + for (TextPos pos : markers.keySet()) { + List> refs = markers.get(pos); + TextPos p; + if (pos.compareTo(start) <= 0) { + // position before the change: keep unchanged + p = pos; + } else if (pos.compareTo(end) < 0) { + // position inside the change: section removed, move marker to start + p = start; + } else { + // position after the change: shift + int ix = pos.index(); + int off; + if (ix == end.index()) { + if ((linesAdded == 0) && (start.index() == end.index())) { + // all on the same line + off = pos.offset() - (end.offset() - start.offset()) + charsTop + charsBottom; + } else { + off = pos.offset() - end.offset() + charsBottom; + } + } else { + // edit happened earlier, offset is unchanged + off = pos.offset(); + } + + ix += (linesAdded - end.index() + start.index()); + p = new TextPos(ix, off); + } + + // update markers with the new position, removing gc'ed + for (int i = refs.size() - 1; i >= 0; i--) { + Marker m = refs.get(i).get(); + if (m == null) { + refs.remove(i); + } else { + MarkerHelper.setMarkerPos(m, p); + } + } + + if (refs.size() > 0) { + m2.put(p, refs); + } + } + + markers = m2; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Origin.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Origin.java new file mode 100644 index 00000000000..bc51b197084 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Origin.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +/** + * View origin: model index of the top paragraph index + offset in pixels from the upper edge of the top cell to + * the upper edge of the view area. + * + * @param index the model index of a paragraph at the top of visible area + * @param offset the distance in pixels from the top of the visible area to the top of the topmost paragraph + */ +public record Origin(int index, double offset) { + /** beginning of the document */ + public static final Origin ZERO = new Origin(0, 0.0); + + @Override + public String toString() { + return "Origin{index=" + index + ", offset=" + offset + "}"; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Params.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Params.java new file mode 100644 index 00000000000..e46e2e1a085 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/Params.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import javafx.geometry.Insets; +import javafx.util.Duration; + +/** + * Various constants. + */ +public class Params { + /** autoscroll animation period, milliseconds. */ + public static final int AUTO_SCROLL_PERIOD = 100; + + /** autoscroll switches to fast mode when mouse is moved further out of the view, pixels. */ + public static final double AUTO_SCROLL_FAST_THRESHOLD = 100; + + /** "fast" autoscroll step, in pixels. */ + public static final double AUTO_SCROLL_STEP_FAST = 200; + + /** "slow" autoscroll step, in pixels. */ + public static final double AUTO_SCROLL_STEP_SLOW = 20; + + /** cell cache size. */ + public static final int CELL_CACHE_SIZE = 512; + + /** default caret blink period. */ + public static final Duration DEFAULT_CARET_BLINK_PERIOD = Duration.millis(1000); + + /** default content padding. */ + public static final Insets DEFAULT_CONTENT_PADDING = null; + + /** default value for {@code displayCaret} property */ + public static final boolean DEFAULT_DISPLAY_CARET = true; + + /** default value for {@code highlightCurrentParagraph} property */ + public static final boolean DEFAULT_HIGHLIGHT_CURRENT_PARAGRAPH = false; + + /** default value for {@code useContentHeight} property */ + public static final boolean DEFAULT_USE_CONTENT_HEIGHT = false; + + /** default tab size in the CodeArea */ + public static final int DEFAULT_TAB_SIZE = 8; + + /** default value for {@code useContentWidth} property */ + public static final boolean DEFAULT_USE_CONTENT_WIDTH = false; + + /** default value for {@code wrapText} property */ + public static final boolean DEFAULT_WRAP_TEXT = false; + + /** small space between the end of last character and the right edge when typing, in pixels. */ + public static final double HORIZONTAL_GUARD = 10; + + /** focus background outline size */ + public static final double LAYOUT_FOCUS_BORDER = 1; + + /** min height of the content area when use content width = true and empty model */ + public static final double LAYOUT_MIN_HEIGHT = 10; + + /** min width of the content area when use content width = true and empty model */ + public static final double LAYOUT_MIN_WIDTH = 20; + + /** prevents lockup when useContentHeight is enabled with a large model */ + public static final double MAX_HEIGHT_SAFEGUARD = 10_000; + + /** maximum width for unwrapped TextFlow layout. Neither Double.MAX_VALUE nor 1e20 work, probably bc float */ + public static final double MAX_WIDTH_FOR_LAYOUT = 1_000_000_000.0; + + /** default minimum height */ + public static final double MIN_HEIGHT = 10; + + /** default minimum width */ + public static final double MIN_WIDTH = 10; + + /** default preferred height */ + public static final double PREF_HEIGHT = 100; + + /** default preferred width */ + public static final double PREF_WIDTH = 200; + + /** scroll bars unit increment, fraction of view width/height (between 0.0 and 1.0). */ + public static final double SCROLL_BARS_UNIT_INCREMENT = 0.1; + + /** horizontal mouse wheel scroll block size as a fraction of window width. */ + public static final double SCROLL_SHEEL_BLOCK_SIZE_HORIZONTAL = 0.1; + + /** vertical mouse wheel scroll block size as a fraction of window height. */ + public static final double SCROLL_WHEEL_BLOCK_SIZE_VERTICAL = 0.1; + + /** + * VFlow TextLayout sliding window extent before and after the visible area. + * Must be > 1.0f for the relative navigation to work. + */ + public static final float SLIDING_WINDOW_EXTENT = 3.0f; +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichParagraphHelper.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichParagraphHelper.java new file mode 100644 index 00000000000..38d26eecbb8 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichParagraphHelper.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.List; +import java.util.function.Consumer; +import com.sun.javafx.util.Utils; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +/** + * Provides access to internal methods in RichParagraph. + */ +public class RichParagraphHelper { + public interface Accessor { + public List> getHighlights(RichParagraph p); + + public List getSegments(RichParagraph p); + } + + static { + Utils.forceInit(RichParagraph.class); + } + + private static Accessor accessor; + + public static void setAccessor(Accessor a) { + if (accessor != null) { + throw new IllegalStateException(); + } + accessor = a; + } + + public static List> getHighlights(RichParagraph p) { + return accessor.getHighlights(p); + } + + public static List getSegments(RichParagraph p) { + return accessor.getSegments(p); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextAreaBehavior.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextAreaBehavior.java new file mode 100644 index 00000000000..02a244e1c69 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextAreaBehavior.java @@ -0,0 +1,1536 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import java.text.Bidi; +import java.text.BreakIterator; +import java.util.function.BiFunction; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.collections.ObservableList; +import javafx.geometry.NodeOrientation; +import javafx.geometry.Rectangle2D; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.DataFormat; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.Pane; +import javafx.stage.Screen; +import javafx.util.Duration; +import com.sun.javafx.PlatformUtil; +import com.sun.javafx.util.Utils; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.input.BehaviorBase; +import jfx.incubator.scene.control.input.KeyBinding; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.DataFormatHandler; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * This class provides the RichTextArea behavior by registering input mappings and + * implementing various event handlers. + */ +public class RichTextAreaBehavior extends BehaviorBase { + private VFlow vflow; + private final Timeline autoScrollTimer; + private boolean autoScrollUp; + private boolean fastAutoScroll; + private boolean scrollStarted; + private double phantomX = -1.0; + private final Duration autoScrollPeriod; + private ContextMenu contextMenu = new ContextMenu(); + + public RichTextAreaBehavior(RichTextArea control) { + super(control); + + autoScrollPeriod = Duration.millis(Params.AUTO_SCROLL_PERIOD); + + autoScrollTimer = new Timeline(new KeyFrame(autoScrollPeriod, (ev) -> { + autoScroll(); + })); + autoScrollTimer.setCycleCount(Timeline.INDEFINITE); + } + + @Override + protected void populateSkinInputMap() { + vflow = RichTextAreaSkinHelper.getVFlow(getControl()); + + // functions + registerFunction(RichTextArea.Tags.BACKSPACE, this::backspace); + registerFunction(RichTextArea.Tags.COPY, this::copy); + registerFunction(RichTextArea.Tags.CUT, this::cut); + registerFunction(RichTextArea.Tags.DELETE, this::delete); + registerFunction(RichTextArea.Tags.DELETE_PARAGRAPH, this::deleteParagraph); + registerFunction(RichTextArea.Tags.DELETE_PARAGRAPH_START, this::deleteParagraphStart); + registerFunction(RichTextArea.Tags.DELETE_WORD_NEXT_END, this::deleteWordNextEnd); + registerFunction(RichTextArea.Tags.DELETE_WORD_NEXT_START, this::deleteWordNextBeg); + registerFunction(RichTextArea.Tags.DELETE_WORD_PREVIOUS, this::deleteWordPrevious); + registerFunction(RichTextArea.Tags.DESELECT, this::deselect); + registerFunction(RichTextArea.Tags.FOCUS_NEXT, this::traverseNext); + registerFunction(RichTextArea.Tags.FOCUS_PREVIOUS, this::traversePrevious); + registerFunction(RichTextArea.Tags.INSERT_LINE_BREAK, this::insertLineBreak); + registerFunction(RichTextArea.Tags.INSERT_TAB, this::insertTab); + registerFunction(RichTextArea.Tags.MOVE_DOWN, this::moveDown); + registerFunction(RichTextArea.Tags.MOVE_LEFT, this::moveLeft); + registerFunction(RichTextArea.Tags.MOVE_PARAGRAPH_DOWN, this::moveParagraphDown); + registerFunction(RichTextArea.Tags.MOVE_PARAGRAPH_UP, this::moveParagraphUp); + registerFunction(RichTextArea.Tags.MOVE_RIGHT, this::moveRight); + registerFunction(RichTextArea.Tags.MOVE_TO_DOCUMENT_END, this::moveDocumentEnd); + registerFunction(RichTextArea.Tags.MOVE_TO_DOCUMENT_START, this::moveDocumentStart); + registerFunction(RichTextArea.Tags.MOVE_TO_PARAGRAPH_END, this::moveParagraphEnd); + registerFunction(RichTextArea.Tags.MOVE_TO_PARAGRAPH_START, this::moveParagraphStart); + registerFunction(RichTextArea.Tags.MOVE_UP, this::moveUp); + registerFunction(RichTextArea.Tags.MOVE_WORD_NEXT_END, this::nextWordEnd); + registerFunction(RichTextArea.Tags.MOVE_WORD_NEXT_START, this::nextWord); + registerFunction(RichTextArea.Tags.MOVE_WORD_LEFT, this::leftWord); + registerFunction(RichTextArea.Tags.MOVE_WORD_PREVIOUS, this::previousWord); + registerFunction(RichTextArea.Tags.MOVE_WORD_RIGHT, this::rightWord); + registerFunction(RichTextArea.Tags.PAGE_DOWN, this::pageDown); + registerFunction(RichTextArea.Tags.PAGE_UP, this::pageUp); + registerFunction(RichTextArea.Tags.PASTE, this::paste); + registerFunction(RichTextArea.Tags.PASTE_PLAIN_TEXT, this::pastePlainText); + registerFunction(RichTextArea.Tags.REDO, this::redo); + registerFunction(RichTextArea.Tags.SELECT_ALL, this::selectAll); + registerFunction(RichTextArea.Tags.SELECT_DOWN, this::selectDown); + registerFunction(RichTextArea.Tags.SELECT_LEFT, this::selectLeft); + registerFunction(RichTextArea.Tags.SELECT_PAGE_DOWN, this::selectPageDown); + registerFunction(RichTextArea.Tags.SELECT_PAGE_UP, this::selectPageUp); + registerFunction(RichTextArea.Tags.SELECT_PARAGRAPH, this::selectParagraph); + registerFunction(RichTextArea.Tags.SELECT_PARAGRAPH_DOWN, this::selectParagraphDown); + registerFunction(RichTextArea.Tags.SELECT_PARAGRAPH_END, this::selectParagraphEnd); + registerFunction(RichTextArea.Tags.SELECT_PARAGRAPH_START, this::selectParagraphStart); + registerFunction(RichTextArea.Tags.SELECT_PARAGRAPH_UP, this::selectParagraphUp); + registerFunction(RichTextArea.Tags.SELECT_RIGHT, this::selectRight); + registerFunction(RichTextArea.Tags.SELECT_TO_DOCUMENT_END, this::selectDocumentEnd); + registerFunction(RichTextArea.Tags.SELECT_TO_DOCUMENT_START, this::selectDocumentStart); + registerFunction(RichTextArea.Tags.SELECT_UP, this::selectUp); + registerFunction(RichTextArea.Tags.SELECT_WORD, this::selectWord); + registerFunction(RichTextArea.Tags.SELECT_WORD_LEFT, this::selectWordLeft); + registerFunction(RichTextArea.Tags.SELECT_WORD_NEXT, this::selectWordNext); + registerFunction(RichTextArea.Tags.SELECT_WORD_NEXT_END, this::selectNextWordEnd); + registerFunction(RichTextArea.Tags.SELECT_WORD_PREVIOUS, this::selectWordPrevious); + registerFunction(RichTextArea.Tags.SELECT_WORD_RIGHT, this::selectWordRight); + registerFunction(RichTextArea.Tags.UNDO, this::undo); + + // key mappings + registerKey(KeyBinding.shortcut(KeyCode.A), RichTextArea.Tags.SELECT_ALL); + registerKey(KeyCode.BACK_SPACE, RichTextArea.Tags.BACKSPACE); + registerKey(KeyBinding.shift(KeyCode.BACK_SPACE), RichTextArea.Tags.BACKSPACE); + registerKey(KeyBinding.shortcut(KeyCode.C), RichTextArea.Tags.COPY); + registerKey(KeyCode.COPY, RichTextArea.Tags.COPY); + registerKey(KeyBinding.shortcut(KeyCode.D), RichTextArea.Tags.DELETE_PARAGRAPH); + registerKey(KeyCode.DELETE, RichTextArea.Tags.DELETE); + registerKey(KeyCode.DOWN, RichTextArea.Tags.MOVE_DOWN); + registerKey(KeyBinding.shift(KeyCode.DOWN), RichTextArea.Tags.SELECT_DOWN); + registerKey(KeyCode.END, RichTextArea.Tags.MOVE_TO_PARAGRAPH_END); + registerKey(KeyBinding.shift(KeyCode.END), RichTextArea.Tags.SELECT_PARAGRAPH_END); + registerKey(KeyCode.ENTER, RichTextArea.Tags.INSERT_LINE_BREAK); + registerKey(KeyCode.HOME, RichTextArea.Tags.MOVE_TO_PARAGRAPH_START); + registerKey(KeyBinding.shift(KeyCode.HOME), RichTextArea.Tags.SELECT_PARAGRAPH_START); + registerKey(KeyBinding.shift(KeyCode.INSERT), RichTextArea.Tags.PASTE); + registerKey(KeyBinding.shortcut(KeyCode.INSERT), RichTextArea.Tags.COPY); + registerKey(KeyCode.LEFT, RichTextArea.Tags.MOVE_LEFT); + registerKey(KeyBinding.shift(KeyCode.LEFT), RichTextArea.Tags.SELECT_LEFT); + registerKey(KeyCode.PAGE_DOWN, RichTextArea.Tags.PAGE_DOWN); + registerKey(KeyBinding.shift(KeyCode.PAGE_DOWN), RichTextArea.Tags.SELECT_PAGE_DOWN); + registerKey(KeyCode.PAGE_UP, RichTextArea.Tags.PAGE_UP); + registerKey(KeyBinding.shift(KeyCode.PAGE_UP), RichTextArea.Tags.SELECT_PAGE_UP); + registerKey(KeyCode.PASTE, RichTextArea.Tags.PASTE); + registerKey(KeyCode.RIGHT, RichTextArea.Tags.MOVE_RIGHT); + registerKey(KeyBinding.shift(KeyCode.RIGHT), RichTextArea.Tags.SELECT_RIGHT); + registerKey(KeyCode.TAB, RichTextArea.Tags.INSERT_TAB); + registerKey(KeyBinding.ctrl(KeyCode.TAB), RichTextArea.Tags.FOCUS_NEXT); + registerKey(KeyBinding.ctrlShift(KeyCode.TAB), RichTextArea.Tags.FOCUS_PREVIOUS); + registerKey(KeyBinding.shift(KeyCode.TAB), RichTextArea.Tags.FOCUS_PREVIOUS); + registerKey(KeyCode.UP, RichTextArea.Tags.MOVE_UP); + registerKey(KeyBinding.shift(KeyCode.UP), RichTextArea.Tags.SELECT_UP); + registerKey(KeyBinding.shortcut(KeyCode.V), RichTextArea.Tags.PASTE); + registerKey(KeyBinding.shiftShortcut(KeyCode.V), RichTextArea.Tags.PASTE_PLAIN_TEXT); + registerKey(KeyBinding.shortcut(KeyCode.X), RichTextArea.Tags.CUT); + registerKey(KeyCode.CUT, RichTextArea.Tags.CUT); + registerKey(KeyBinding.shortcut(KeyCode.Z), RichTextArea.Tags.UNDO); + + if (isMac()) { + registerKey(KeyBinding.option(KeyCode.BACK_SPACE), RichTextArea.Tags.DELETE_WORD_PREVIOUS); + registerKey(KeyBinding.shortcut(KeyCode.BACK_SPACE), RichTextArea.Tags.DELETE_PARAGRAPH_START); + registerKey(KeyBinding.option(KeyCode.DELETE), RichTextArea.Tags.DELETE_WORD_NEXT_END); + registerKey(KeyBinding.option(KeyCode.DOWN), RichTextArea.Tags.MOVE_PARAGRAPH_DOWN); + registerKey(KeyBinding.shiftOption(KeyCode.DOWN), RichTextArea.Tags.SELECT_PARAGRAPH_DOWN); + registerKey(KeyBinding.shiftShortcut(KeyCode.DOWN), RichTextArea.Tags.SELECT_TO_DOCUMENT_END); + registerKey(KeyBinding.shortcut(KeyCode.DOWN), RichTextArea.Tags.MOVE_TO_DOCUMENT_END); + registerKey(KeyBinding.option(KeyCode.LEFT), RichTextArea.Tags.MOVE_WORD_LEFT); + registerKey(KeyBinding.shiftOption(KeyCode.LEFT), RichTextArea.Tags.SELECT_WORD_LEFT); + registerKey(KeyBinding.shiftShortcut(KeyCode.LEFT), RichTextArea.Tags.SELECT_PARAGRAPH_START); + registerKey(KeyBinding.shortcut(KeyCode.LEFT), RichTextArea.Tags.MOVE_TO_PARAGRAPH_START); + registerKey(KeyBinding.option(KeyCode.RIGHT), RichTextArea.Tags.MOVE_WORD_RIGHT); + registerKey(KeyBinding.shiftOption(KeyCode.RIGHT), RichTextArea.Tags.SELECT_WORD_RIGHT); + registerKey(KeyBinding.shiftShortcut(KeyCode.RIGHT), RichTextArea.Tags.SELECT_PARAGRAPH_END); + registerKey(KeyBinding.shortcut(KeyCode.RIGHT), RichTextArea.Tags.MOVE_TO_PARAGRAPH_END); + registerKey(KeyBinding.builder(KeyCode.TAB).ctrl().option().shift().build(), RichTextArea.Tags.FOCUS_NEXT); + registerKey(KeyBinding.option(KeyCode.UP), RichTextArea.Tags.MOVE_PARAGRAPH_UP); + registerKey(KeyBinding.shiftOption(KeyCode.UP), RichTextArea.Tags.SELECT_PARAGRAPH_UP); + registerKey(KeyBinding.shiftShortcut(KeyCode.UP), RichTextArea.Tags.SELECT_TO_DOCUMENT_START); + registerKey(KeyBinding.shortcut(KeyCode.UP), RichTextArea.Tags.MOVE_TO_DOCUMENT_START); + registerKey(KeyBinding.with(KeyCode.Z).shift().command().build(), RichTextArea.Tags.REDO); + } else { + registerKey(KeyBinding.ctrl(KeyCode.BACK_SLASH), RichTextArea.Tags.DESELECT); + registerKey(KeyBinding.ctrl(KeyCode.BACK_SPACE), RichTextArea.Tags.DELETE_WORD_PREVIOUS); + registerKey(KeyBinding.ctrl(KeyCode.DELETE), RichTextArea.Tags.DELETE_WORD_NEXT_START); + registerKey(KeyBinding.ctrl(KeyCode.DOWN), RichTextArea.Tags.MOVE_PARAGRAPH_DOWN); + registerKey(KeyBinding.ctrlShift(KeyCode.DOWN), RichTextArea.Tags.SELECT_PARAGRAPH_DOWN); + registerKey(KeyBinding.ctrl(KeyCode.H), RichTextArea.Tags.BACKSPACE); + registerKey(KeyBinding.ctrl(KeyCode.HOME), RichTextArea.Tags.MOVE_TO_DOCUMENT_START); + registerKey(KeyBinding.ctrlShift(KeyCode.HOME), RichTextArea.Tags.SELECT_TO_DOCUMENT_START); + registerKey(KeyBinding.shift(KeyCode.HOME), RichTextArea.Tags.SELECT_PARAGRAPH_START); + registerKey(KeyBinding.ctrl(KeyCode.END), RichTextArea.Tags.MOVE_TO_DOCUMENT_END); + registerKey(KeyBinding.ctrlShift(KeyCode.END), RichTextArea.Tags.SELECT_TO_DOCUMENT_END); + registerKey(KeyBinding.shift(KeyCode.END), RichTextArea.Tags.SELECT_PARAGRAPH_END); + registerKey(KeyBinding.ctrl(KeyCode.LEFT), RichTextArea.Tags.MOVE_WORD_LEFT); + registerKey(KeyBinding.ctrlShift(KeyCode.LEFT), RichTextArea.Tags.SELECT_WORD_LEFT); + registerKey(KeyBinding.ctrl(KeyCode.RIGHT), RichTextArea.Tags.MOVE_WORD_RIGHT); + registerKey(KeyBinding.ctrlShift(KeyCode.RIGHT), RichTextArea.Tags.SELECT_WORD_RIGHT); + registerKey(KeyBinding.ctrl(KeyCode.UP), RichTextArea.Tags.MOVE_PARAGRAPH_UP); + registerKey(KeyBinding.ctrlShift(KeyCode.UP), RichTextArea.Tags.SELECT_PARAGRAPH_UP); + + if (isWindows()) { + registerKey(KeyBinding.ctrl(KeyCode.Y), RichTextArea.Tags.REDO); + } else { + registerKey(KeyBinding.ctrlShift(KeyCode.Z), RichTextArea.Tags.REDO); + } + } + + Pane cp = vflow.getContentPane(); + cp.addEventFilter(MouseEvent.MOUSE_CLICKED, this::handleMouseClicked); + cp.addEventFilter(MouseEvent.MOUSE_PRESSED, this::handleMousePressed); + cp.addEventFilter(MouseEvent.MOUSE_RELEASED, this::handleMouseReleased); + cp.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::handleMouseDragged); + cp.addEventFilter(ScrollEvent.SCROLL_STARTED, this::handleScrollEventStarted); + cp.addEventHandler(ScrollEvent.SCROLL_FINISHED, this::handleScrollEventFinished); + cp.addEventHandler(ScrollEvent.SCROLL, this::handleScrollEvent); + + addHandler(KeyEvent.KEY_TYPED, true, this::handleKeyTyped); + addHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, true, this::contextMenuRequested); + } + + protected boolean isRTL() { + return (getControl().getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT); + } + + protected String getPlainText(int modelIndex) { + StyledTextModel m = getControl().getModel(); + return (m == null) ? null : m.getPlainText(modelIndex); + } + + protected void handleKeyTyped(KeyEvent ev) { + if (ev == null || ev.isConsumed()) { + return; + } + + // TODO something about consuming all key presses (yes) and key releases (not really) + // in TextInputControlBehavior:194 + + String character = getValidKeyTyped(ev); + if (character != null) { + vflow.setSuppressBlink(true); + boolean consume = handleTypedChar(character); + if (consume) { + ev.consume(); + } + vflow.setSuppressBlink(false); + } + } + + protected boolean handleTypedChar(String typed) { + if (canEdit()) { + RichTextArea control = getControl(); + StyledTextModel m = control.getModel(); + TextPos start = control.getCaretPosition(); + if (start != null) { + TextPos end = control.getAnchorPosition(); + if (end == null) { + end = start; + } + + TextPos p = m.replace(vflow, start, end, typed, true); + moveCaret(p, false); + + clearPhantomX(); + return true; + } + } + return false; + } + + protected String getValidKeyTyped(KeyEvent ev) { + if (ev.getEventType() == KeyEvent.KEY_TYPED) { + String ch = ev.getCharacter(); + if (ch.length() > 0) { + // see TextInputControlBehavior:395 + // Filter out control keys except control+Alt on PC or Alt on Mac + if (ev.isControlDown() || ev.isAltDown() || (PlatformUtil.isMac() && ev.isMetaDown())) { + if (!((ev.isControlDown() || PlatformUtil.isMac()) && ev.isAltDown())) { + return null; + } + } + + // Ignore characters in the control range and the ASCII delete + // character as well as meta key presses + if (ch.charAt(0) > 0x1F && ch.charAt(0) != 0x7F && !ev.isMetaDown()) { + // Not sure about this one (original comment, not sure about it either) + return ch; + } + } + } + return null; + } + + /** returns true if both control and model are editable */ + protected boolean canEdit() { + RichTextArea control = getControl(); + if (control.isEditable()) { + StyledTextModel m = control.getModel(); + if (m != null) { + return m.isUserEditable(); + } + } + return false; + } + + public void insertTab() { + if (canEdit()) { + handleTypedChar("\t"); + } else { + traverseNext(); + } + } + + public void insertLineBreak() { + if (canEdit()) { + RichTextArea control = getControl(); + StyledTextModel m = control.getModel(); + TextPos start = control.getCaretPosition(); + if (start == null) { + return; + } + TextPos end = control.getAnchorPosition(); + if (end == null) { + return; + } + + TextPos pos = m.replace(vflow, start, end, StyledInput.of("\n"), true); + moveCaret(pos, false); + clearPhantomX(); + } + } + + protected void handleMouseClicked(MouseEvent ev) { + if (ev.getButton() == MouseButton.PRIMARY) { + int clicks = ev.getClickCount(); + switch (clicks) { + case 2: + getControl().selectWord(); + break; + case 3: + getControl().selectParagraph(); + break; + } + } + } + + protected void handleMousePressed(MouseEvent ev) { + if (ev.isPopupTrigger() || (ev.getButton() != MouseButton.PRIMARY)) { + return; + } + + TextPos pos = getTextPosition(ev); + if (pos == null) { + return; + } + + vflow.setSuppressBlink(true); + + RichTextArea control = getControl(); + if (ev.isShiftDown()) { + // expand selection from the anchor point to the current position + // clearing existing selection + control.extendSelection(pos); + } else { + control.select(pos, pos); + } + + if (contextMenu.isShowing()) { + contextMenu.hide(); + } + + control.requestFocus(); + } + + protected void handleMouseReleased(MouseEvent ev) { + stopAutoScroll(); + vflow.scrollCaretToVisible(); + vflow.setSuppressBlink(false); + clearPhantomX(); + } + + protected void handleMouseDragged(MouseEvent ev) { + if (!(ev.getButton() == MouseButton.PRIMARY)) { + return; + } + + double y = ev.getY(); + if (y < 0.0) { + // above visible area + autoScroll(y); + return; + } else if (y > vflow.getViewHeight()) { + // below visible area + autoScroll(y - vflow.getViewHeight()); + return; + } else { + stopAutoScroll(); + } + + TextPos pos = getTextPosition(ev); + getControl().extendSelection(pos); + } + + private void handleScrollEventStarted(ScrollEvent ev) { + scrollStarted = true; + } + + private void handleScrollEventFinished(ScrollEvent ev) { + scrollStarted = false; + } + + private void handleScrollEvent(ScrollEvent ev) { + RichTextArea control = getControl(); + double dx = ev.getDeltaX(); + if (dx != 0.0) { + // horizontal + if (!control.isWrapText() && !control.isUseContentWidth()) { + if(scrollStarted) { + // trackpad + vflow.scrollHorizontalPixels(-ev.getDeltaX()); + } else { + // mouse + double f = Params.SCROLL_SHEEL_BLOCK_SIZE_HORIZONTAL; + if (dx >= 0) { + f = -f; + } + vflow.scrollHorizontalFraction(f); + } + ev.consume(); + } + } + + double dy = ev.getDeltaY(); + if (dy != 0.0) { + // vertical + if (!control.isUseContentHeight()) { + if(scrollStarted) { + // trackpad + vflow.scrollVerticalPixels(-dy); + } else { + // mouse + if (ev.isShortcutDown()) { + // page up / page down + if (dy >= 0) { + vflow.pageUp(); + } else { + vflow.pageDown(); + } + } else { + // block scroll + double f = Params.SCROLL_WHEEL_BLOCK_SIZE_VERTICAL; + if (dy >= 0) { + f = -f; + } + vflow.scrollVerticalFraction(f); + } + } + ev.consume(); + } + } + } + + protected TextPos getTextPosition(MouseEvent ev) { + double x = ev.getScreenX(); + double y = ev.getScreenY(); + return getControl().getTextPosition(x, y); + } + + protected void stopAutoScroll() { + autoScrollTimer.stop(); + } + + protected void autoScroll(double delta) { + autoScrollUp = (delta < 0.0); + fastAutoScroll = Math.abs(delta) > Params.AUTO_SCROLL_FAST_THRESHOLD; + autoScrollTimer.play(); + } + + protected void autoScroll() { + RichTextArea control = getControl(); + if (control.isUseContentHeight()) { + return; + } + double delta = fastAutoScroll ? Params.AUTO_SCROLL_STEP_FAST : Params.AUTO_SCROLL_STEP_SLOW; + if (autoScrollUp) { + delta = -delta; + } + vflow.scrollVerticalPixels(delta, true); + + double x = Math.max(0.0, phantomX + vflow.getOffsetX()); + double y = autoScrollUp ? 0.0 : vflow.getViewHeight(); + + vflow.scrollToVisible(x, y); + + TextPos p = vflow.getTextPosLocal(x, y); + control.extendSelection(p); + } + + public void pageDown() { + moveLine(vflow.getViewHeight(), false); + } + + public void pageUp() { + moveLine(-vflow.getViewHeight(), false); + } + + public void moveRight() { + moveCharacter(true, false); + } + + public void moveLeft() { + moveCharacter(false, false); + } + + public void moveParagraphStart() { + moveCaret(false, this::paragraphStart); + } + + public void moveParagraphEnd() { + moveCaret(false, this::paragraphEnd); + } + + public void selectParagraphStart() { + moveCaret(true, this::paragraphStart); + } + + public void selectParagraphEnd() { + moveCaret(true, this::paragraphEnd); + } + + public void moveParagraphDown() { + moveCaret(false, this::paragraphDown); + } + + public void selectParagraphDown() { + moveCaret(true, this::paragraphDown); + } + + public void moveParagraphUp() { + moveCaret(false, this::paragraphUp); + } + + public void selectParagraphUp() { + moveCaret(true, this::paragraphUp); + } + + private TextPos paragraphDown(RichTextArea control, TextPos caret) { + int ix = caret.index(); + TextPos end = control.getParagraphEnd(ix); + if (caret.isSameInsertionIndex(end)) { + ix++; + if (ix >= control.getParagraphCount()) { + return null; + } + return control.getParagraphEnd(ix); + } + return end; + } + + public TextPos paragraphUp(RichTextArea control, TextPos caret) { + int ix = caret.index(); + TextPos p = new TextPos(ix, 0); + if (caret.isSameInsertionIndex(p)) { + --ix; + if (ix < 0) { + return null; + } + p = new TextPos(ix, 0); + } + return p; + } + + private TextPos paragraphEnd(RichTextArea control, TextPos caret) { + int ix = caret.index(); + return control.getParagraphEnd(ix); + } + + private void moveCaret(boolean extSelection, BiFunction h) { + RichTextArea control = getControl(); + TextPos caret = control.getCaretPosition(); + if (caret != null) { + TextPos p = h.apply(control, caret); + if (p != null) { + clearPhantomX(); + moveCaret(p, extSelection); + } + } + } + + private TextPos paragraphStart(RichTextArea control, TextPos caret) { + return new TextPos(caret.index(), 0); + } + + public void moveUp() { + moveLine(-1.0, false); + } + + public void moveDown() { + moveLine(1.0, false); + } + + /** + * Moves the caret to before the first character of the text, also clearing the selection. + */ + public void moveDocumentStart() { + RichTextArea control = getControl(); + control.select(TextPos.ZERO); + } + + /** + * Moves the caret to after the last character of the text, also clearing the selection. + */ + public void moveDocumentEnd() { + RichTextArea control = getControl(); + TextPos pos = control.getDocumentEnd(); + control.select(pos); + } + + protected void moveLine(double deltaPixels, boolean extendSelection) { + CaretInfo c = vflow.getCaretInfo(); + if (c == null) { + return; + } + double sp = c.getLineSpacing(); + double x = (c.getMinX() + c.getMaxX()) / 2.0; + double y = (deltaPixels < 0) ? c.getMinY() + deltaPixels - sp - 0.5 : c.getMaxY() + deltaPixels + sp; + + if (phantomX < 0) { + // phantom x is unclear in the case of split caret + phantomX = x; + } else { + x = phantomX; + } + + TextPos p = vflow.getTextPosLocal(x + vflow.leftPadding(), y); + if (p != null) { + moveCaret(p, extendSelection); + } + } + + protected void moveCharacter(boolean moveRight, boolean extendSelection) { + // TODO bidi + RichTextArea control = getControl(); + TextPos caret = control.getCaretPosition(); + if (caret == null) { + return; + } + + clearPhantomX(); + + if (!extendSelection) { + TextPos ca = control.getCaretPosition(); + TextPos an = control.getAnchorPosition(); + int d = ca.compareTo(an); + // jump over selection if it exists + if (d < 0) { + moveCaret(moveRight ? an : ca, extendSelection); + return; + } else if (d > 0) { + moveCaret(moveRight ? ca : an, extendSelection); + return; + } + } + + TextPos p = nextCharacterVisually(caret, moveRight); + if (p != null) { + moveCaret(p, extendSelection); + } + } + + protected TextPos nextCharacterVisually(TextPos start, boolean moveRight) { + if (isRTL()) { + moveRight = !moveRight; + } + + RichTextArea control = getControl(); + TextCell cell = vflow.getCell(start.index()); + int cix = start.offset(); + if (moveRight) { + cix++; + if (cix > cell.getTextLength()) { + int ix = cell.getIndex() + 1; + TextPos p; + if (ix < control.getParagraphCount()) { + // next line + p = new TextPos(ix, 0); + } else { + // end of last paragraph w/o newline + p = new TextPos(cell.getIndex(), cell.getTextLength()); + } + return p; + } + } else { + if (start.offset() == 0) { + int ix = cell.getIndex() - 1; + if (ix >= 0) { + // end of prev line + return control.getParagraphEnd(ix); + } + return null; + } + } + + // using default locale, same as TextInputControl.backward() for example + BreakIterator br = BreakIterator.getCharacterInstance(); + String text = getPlainText(cell.getIndex()); + br.setText(text); + int off = start.offset(); + try { + int ix = moveRight ? br.following(off) : br.preceding(off); + if (ix == BreakIterator.DONE) { + System.err.println(" --- SHOULD NOT HAPPEN: BreakIterator.DONE off=" + off); // FIX + return null; + } + return new TextPos(start.index(), ix); + } catch(Exception e) { + // TODO need to use a logger! + System.err.println("offset=" + off + " text=[" + text + "]"); // FIX + e.printStackTrace(); + return null; + } + } + + /** + * Moves the caret and anchor to the new position, unless {@code extendSelection} is true, in which case + * extend selection from the existing anchor to the newly set caret position. + * @param p text position + * @param extendSelection specifies whether to clear (false) or extend (true) any existing selection + */ + protected void moveCaret(TextPos p, boolean extendSelection) { + RichTextArea control = getControl(); + if (extendSelection) { + control.extendSelection(p); + } else { + control.select(p, p); + } + } + + public void clearPhantomX() { + phantomX = -1.0; + } + + public void selectLeft() { + moveCharacter(false, true); + } + + public void selectRight() { + moveCharacter(true, true); + } + + public void selectUp() { + moveLine(-1.0, true); + } + + public void selectDown() { + moveLine(1.0, true); + } + + public void selectPageDown() { + moveLine(vflow.getViewHeight(), true); + } + + public void selectPageUp() { + moveLine(-vflow.getViewHeight(), true); + } + + public void selectAll() { + RichTextArea control = getControl(); + TextPos end = control.getDocumentEnd(); + control.select(TextPos.ZERO, end); + clearPhantomX(); + } + + /** selects from the anchor position to the document start */ + public void selectDocumentStart() { + getControl().extendSelection(TextPos.ZERO); + } + + /** selects from the anchor position to the document end */ + public void selectDocumentEnd() { + RichTextArea control = getControl(); + TextPos pos = control.getDocumentEnd(); + control.extendSelection(pos); + } + + public void selectWord() { + RichTextArea control = getControl(); + TextPos caret = control.getCaretPosition(); + if (caret == null) { + return; + } + + int index = caret.index(); + String text = getPlainText(index); + if (text == null) { + return; + } + + // using default locale, same as TextInputControl.backward() for example + BreakIterator br = BreakIterator.getWordInstance(); + br.setText(text); + int off = caret.offset(); + try { + int off0 = br.preceding(off); + if (off0 == BreakIterator.DONE) { + //System.err.println(" --- no previous word off=" + off); // FIX + return; + } + + int off1 = br.following(off); + if (off1 == BreakIterator.DONE) { + //System.err.println(" --- no following word off=" + off); // FIX + return; + } + + TextPos p0 = new TextPos(index, off0); + TextPos p1 = new TextPos(index, off1); + control.select(p0, p1); + } catch (Exception e) { + // TODO need to use a logger! + System.err.println("offset=" + off + " text=[" + text + "]"); + e.printStackTrace(); + } + } + + public void selectParagraph() { + RichTextArea control = getControl(); + TextPos p = control.getCaretPosition(); + if (p != null) { + int ix = p.index(); + TextPos an = new TextPos(ix, 0); + TextPos ca = control.getParagraphEnd(ix); + control.select(an, ca); + } + } + + public void backspace() { + if (canEdit()) { + RichTextArea control = getControl(); + if (control.hasNonEmptySelection()) { + deleteSelection(); + } else { + TextPos p = control.getCaretPosition(); + if (p == null) { + return; + } + + int ix = p.index(); + + TextPos start; + if (p.offset() == 0) { + if (ix == 0) { + return; + } + int off = getPlainText(ix - 1).length(); + start = new TextPos(ix - 1, off); + } else { + String text = getPlainText(p.index()); + // Do not use charIterator here, because we do want to + // break up clusters when deleting backwards. + int off = Character.offsetByCodePoints(text, p.offset(), -1); + start = new TextPos(ix, off); + } + + control.getModel().replace(vflow, start, p, StyledInput.EMPTY, true); + moveCaret(start, false); + clearPhantomX(); + } + } + } + + public void delete() { + if (canEdit()) { + RichTextArea control = getControl(); + if (control.hasNonEmptySelection()) { + deleteSelection(); + } else { + TextPos start = control.getCaretPosition(); + TextPos end = nextCharacterVisually(start, true); + if (end != null) { + control.getModel().replace(vflow, start, end, StyledInput.EMPTY, true); + moveCaret(start, false); + clearPhantomX(); + } + } + } + } + + private SelInfo sel() { + return SelInfo.get(getControl()); + } + + private TextPos clamp(TextPos p) { + return getControl().getModel().clamp(p); + } + + public void deleteParagraph() { + if (canEdit()) { + SelInfo sel = sel(); + if (sel != null) { + int ix0 = sel.getMin().index(); + int ix1 = sel.getMax().index(); + + TextPos p0 = new TextPos(ix0, 0); + TextPos p1 = clamp(new TextPos(ix1 + 1, 0)); + RichTextArea control = getControl(); + control.getModel().replace(vflow, p0, p1, StyledInput.EMPTY, true); + clearPhantomX(); + moveCaret(p0, false); + } + } + } + + public void deleteParagraphStart() { + deleteIgnoreSelection(this::paragraphStart); + } + + protected void deleteSelection() { + SelInfo sel = sel(); + if (sel != null) { + TextPos start = sel.getMin(); + TextPos end = sel.getMax(); + RichTextArea control = getControl(); + control.getModel().replace(vflow, start, end, StyledInput.EMPTY, true); + clearPhantomX(); + moveCaret(start, false); + } + } + + protected void deselect() { + RichTextArea control = getControl(); + TextPos p = control.getCaretPosition(); + if (p != null) { + clearPhantomX(); + moveCaret(p, false); + } + } + + // see TextAreaBehavior:338 + public void contextMenuRequested(ContextMenuEvent ev) { + RichTextArea control = getControl(); + if (contextMenu.isShowing()) { + contextMenu.hide(); + } else if (control.getContextMenu() == null && control.getOnContextMenuRequested() == null) { + double screenX = ev.getScreenX(); + double screenY = ev.getScreenY(); + double sceneX = ev.getSceneX(); + + /* TODO + if (NewAPI.isTouchSupported()) { + Point2D menuPos; + if (control.getSelection().getLength() == 0) { + skin.positionCaret(skin.getIndex(ev.getX(), ev.getY()), false); + menuPos = skin.getMenuPosition(); + } else { + menuPos = skin.getMenuPosition(); + if (menuPos != null && (menuPos.getX() <= 0 || menuPos.getY() <= 0)) { + skin.positionCaret(skin.getIndex(ev.getX(), ev.getY()), false); + menuPos = skin.getMenuPosition(); + } + } + + if (menuPos != null) { + Point2D p = control.localToScene(menuPos); + Scene scene = control.getScene(); + Window window = scene.getWindow(); + Point2D location = new Point2D(window.getX() + scene.getX() + p.getX(), + window.getY() + scene.getY() + p.getY()); + screenX = location.getX(); + sceneX = p.getX(); + screenY = location.getY(); + } + } + */ + + populateContextMenu(); + + double menuWidth = contextMenu.prefWidth(-1); + double menuX = screenX - (RichUtils.isTouchSupported() ? (menuWidth / 2) : 0); + Screen currentScreen = Utils.getScreenForPoint(screenX, 0); + Rectangle2D bounds = currentScreen.getBounds(); + + // what is this?? + if (menuX < bounds.getMinX()) { + control.getProperties().put("CONTEXT_MENU_SCREEN_X", screenX); + control.getProperties().put("CONTEXT_MENU_SCENE_X", sceneX); + contextMenu.show(control, bounds.getMinX(), screenY); + } else if (screenX + menuWidth > bounds.getMaxX()) { + double leftOver = menuWidth - (bounds.getMaxX() - screenX); + control.getProperties().put("CONTEXT_MENU_SCREEN_X", screenX); + control.getProperties().put("CONTEXT_MENU_SCENE_X", sceneX); + contextMenu.show(control, screenX - leftOver, screenY); + } else { + control.getProperties().put("CONTEXT_MENU_SCREEN_X", 0); + control.getProperties().put("CONTEXT_MENU_SCENE_X", 0); + contextMenu.show(control, menuX, screenY); + } + } + + ev.consume(); + } + + // TODO this might belong to the control! + protected void populateContextMenu() { + RichTextArea control = getControl(); + boolean sel = control.hasNonEmptySelection(); + boolean paste = (findFormatForPaste() != null); + boolean editable = control.canEdit(); + + ObservableList items = contextMenu.getItems(); + items.clear(); + + MenuItem m; + items.add(m = new MenuItem("Undo")); + m.setOnAction((ev) -> control.undo()); + m.setDisable(!control.isUndoable()); + + items.add(m = new MenuItem("Redo")); + m.setOnAction((ev) -> control.redo()); + m.setDisable(!control.isRedoable()); + + items.add(new SeparatorMenuItem()); + + items.add(m = new MenuItem("Cut")); + m.setOnAction((ev) -> control.cut()); + m.setDisable(!sel || !editable); + + items.add(m = new MenuItem("Copy")); + m.setOnAction((ev) -> control.copy()); + m.setDisable(!sel); + + items.add(m = new MenuItem("Paste")); + m.setOnAction((ev) -> control.paste()); + m.setDisable(!paste || !editable); + + items.add(new SeparatorMenuItem()); + + items.add(m = new MenuItem("Select All")); + m.setOnAction((ev) -> control.selectAll()); + } + + public void copy() { + copy(false); + } + + public void cut() { + copy(true); + } + + public void paste() { + if (canEdit()) { + DataFormat f = findFormatForPaste(); + if (f != null) { + pasteLocal(f); + } + } + } + + public void paste(DataFormat f) { + if (canEdit()) { + Clipboard c = Clipboard.getSystemClipboard(); + if (c.hasContent(f)) { + pasteLocal(f); + } + } + } + + public void pastePlainText() { + paste(DataFormat.PLAIN_TEXT); + } + + /** + * returns a format that can be imported by a model, based on the clipboard content and model being editable. + */ + protected DataFormat findFormatForPaste() { + if (canEdit()) { + StyledTextModel m = getControl().getModel(); + DataFormat[] fs = m.getSupportedDataFormats(false); + if (fs.length > 0) { + for (DataFormat f : fs) { + if (Clipboard.getSystemClipboard().hasContent(f)) { + return f; + } + } + } + } + return null; + } + + private void pasteLocal(DataFormat f) { + SelInfo sel = sel(); + if (sel != null) { + RichTextArea control = getControl(); + TextPos start = sel.getMin(); + TextPos end = sel.getMax(); + + StyledTextModel m = control.getModel(); + DataFormatHandler h = m.getDataFormatHandler(f, false); + Object x = Clipboard.getSystemClipboard().getContent(f); + String text; + if (x instanceof String s) { + text = s; + } else { + return; + } + + StyleAttrs a = control.getActiveStyleAttrs(); + try (StyledInput in = h.createStyledInput(text, a)) { + TextPos p = m.replace(vflow, start, end, in, true); + moveCaret(p, false); + } catch (IOException e) { + RichUtils.provideErrorFeedback(control, e); + } + } + } + + protected void copy(boolean cut) { + RichTextArea control = getControl(); + if (control.hasNonEmptySelection()) { + StyledTextModel m = control.getModel(); // non null at this point + DataFormat[] fs = m.getSupportedDataFormats(true); + if (fs.length > 0) { + SelInfo sel = sel(); + if (sel == null) { + return; + } + + TextPos start = sel.getMin(); + TextPos end = sel.getMax(); + + try { + ClipboardContent c = new ClipboardContent(); + for (DataFormat f : fs) { + DataFormatHandler h = m.getDataFormatHandler(f, true); + Object v = h.copy(m, vflow, start, end); + if (v != null) { + c.put(f, v); + } + } + Clipboard.getSystemClipboard().setContent(c); + + if (canEdit() && cut) { + deleteSelection(); + } + } catch(Exception | OutOfMemoryError e) { + RichUtils.provideErrorFeedback(control, e); + } + } + } + } + + public void copy(DataFormat f) { + RichTextArea control = getControl(); + try { + if (control.hasNonEmptySelection()) { + StyledTextModel m = control.getModel(); // not null at this point + DataFormatHandler h = m.getDataFormatHandler(f, true); + if (h != null) { + SelInfo sel = sel(); + if (sel == null) { + return; + } + + TextPos start = sel.getMin(); + TextPos end = sel.getMax(); + + Object v = h.copy(m, vflow, start, end); + ClipboardContent c = new ClipboardContent(); + c.put(f, v); + Clipboard.getSystemClipboard().setContent(c); + } + } + } catch(Exception | OutOfMemoryError e) { + RichUtils.provideErrorFeedback(control, e); + } + } + + /** + * Moves the caret to the beginning of previous word. This function + * also has the effect of clearing the selection. + */ + public void previousWord() { + moveCaret(false, this::previousWord); + } + + /** moves the caret to the beginning of the previos word (LTR) or next word (RTL) */ + public void leftWord() { + leftWord(false); + } + + /** moves the caret to the beginning of the next word (LTR) or previous word (RTL) */ + public void rightWord() { + rightWord(false); + } + + /** + * Moves the caret to the beginning of next word. This function + * also has the effect of clearing the selection. + */ + public void nextWord() { + moveCaret(false, this::nextWordBeg); + } + + /** + * Moves the caret to the end of the next word. This function + * also has the effect of clearing the selection. + */ + public void nextWordEnd() { + moveCaret(false, this::nextWordEnd); + } + + /** + * Moves the caret to the beginning of previous word. This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of previous word. + */ + public void selectWordPrevious() { + moveCaret(true, this::previousWord); + } + + /** + * Moves the caret to the beginning of next word. This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of next word. + */ + public void selectWordNext() { + moveCaret(true, this::nextWordBeg); + } + + /** + * Moves the caret to the end of the next word. This does not cause + * the selection to be cleared. + */ + public void selectNextWordEnd() { + moveCaret(true, this::nextWordEnd); + } + + /** + * Moves the caret to the beginning of previous word (LTR) or next word (LTR). + * This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of previous word. + */ + public void selectWordLeft() { + leftWord(true); + } + + /** + * Moves the caret to the beginning of next word (LTR) or previous word (RTL). + * This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of next word. + */ + public void selectWordRight() { + rightWord(true); + } + + protected void leftWord(boolean extendSelection) { + if (isRTLText()) { + if (isWindows()) { + moveCaret(extendSelection, this::nextWordBeg); + } else { + moveCaret(extendSelection, this::nextWordEnd); + } + } else { + moveCaret(extendSelection, this::previousWord); + } + } + + protected void rightWord(boolean extendSelection) { + if (isRTLText()) { + moveCaret(extendSelection, this::previousWord); + } else { + if (isWindows()) { + moveCaret(extendSelection, this::nextWordBeg); + } else { + moveCaret(extendSelection, this::nextWordEnd); + } + } + } + + protected TextPos previousWord(RichTextArea control, TextPos pos) { + int index = pos.index(); + int offset = pos.offset(); + BreakIterator br = null; + + for (;;) { + if ((index == 0) && (offset <= 0)) { + return TextPos.ZERO; + } + + String text = getPlainText(index); + if ((text == null) || (text.length() == 0)) { + index--; + offset = Integer.MAX_VALUE; + continue; + } + + if (br == null) { + br = BreakIterator.getWordInstance(); + } + br.setText(text); + + int len = text.length(); + int off = br.preceding(Utils.clamp(0, offset, len)); + + while (off != BreakIterator.DONE && !RichUtils.isLetterOrDigit(text, off)) { + off = br.preceding(Utils.clamp(0, off, len)); + } + + if (off < 0) { + index--; + offset = Integer.MAX_VALUE; + continue; + } + return new TextPos(index, off); + } + } + + // skips empty paragraphs + protected TextPos nextWordBeg(RichTextArea control, TextPos pos) { + int index = pos.index(); + int offset = pos.offset(); + boolean skipEmpty = true; + + for (;;) { + TextPos end = control.getDocumentEnd(); + // this could be a isSameOrAfter(index, off) method in TextPos + if ((index == end.index()) && (offset >= end.offset())) { + return end; + } else if (index > end.index()) { + return end; + } + + String text = getPlainText(index); + if ((text == null) || (text.length() == 0)) { + if (skipEmpty) { + index++; + } + return new TextPos(index, 0); + } + + BreakIterator br = BreakIterator.getWordInstance(); + br.setText(text); + + int len = text.length(); + if (offset == len) { + return new TextPos(++index, 0); + } + + int next = br.following(Utils.clamp(0, offset, len)); + if ((next == BreakIterator.DONE) || (next == len)) { + return new TextPos(index, len); + } else { + while (next != BreakIterator.DONE) { + boolean inWord = RichUtils.isLetterOrDigit(text, next); + if (inWord) { + return new TextPos(index, next); + } + next = br.next(); + } + } + + index++; + offset = 0; + skipEmpty = false; + } + } + + // skips empty paragraphs + protected TextPos nextWordEnd(RichTextArea control, TextPos pos) { + int index = pos.index(); + int offset = pos.offset(); + boolean skipEmpty = true; + + for (;;) { + TextPos end = control.getDocumentEnd(); + // this could be a isSameOrAfter(index, off) method in TextPos + if ((index == end.index()) && (offset >= end.offset())) { + return end; + } else if (index > end.index()) { + return end; + } + + String text = getPlainText(index); + if ((text == null) || (text.length() == 0)) { + if (skipEmpty) { + index++; + } + return new TextPos(index, 0); + } + + BreakIterator br = BreakIterator.getWordInstance(); + br.setText(text); + + boolean inWord = RichUtils.isLetterOrDigit(text, offset); + int len = text.length(); + int next = br.following(Utils.clamp(0, offset, len)); + if (next == BreakIterator.DONE) { + if (inWord) { + // when starting in the middle of a word + return new TextPos(index, len); + } + } else { + if (inWord) { + return new TextPos(index, next); + } + + while (next != BreakIterator.DONE) { + offset = next; + next = br.next(); + inWord = RichUtils.isLetterOrDigit(text, offset); + if (inWord) { + return new TextPos(index, next); + } + } + } + + index++; + offset = 0; + skipEmpty = false; + } + } + + public void redo() { + RichTextArea control = getControl(); + StyledTextModel m = control.getModel(); + if (m != null) { + TextPos[] sel = m.redo(vflow); + if (sel != null) { + control.select(sel[0], sel[1]); + } + } + } + + public void undo() { + RichTextArea control = getControl(); + StyledTextModel m = control.getModel(); + if (m != null) { + TextPos[] sel = m.undo(vflow); + if (sel != null) { + control.select(sel[0], sel[1]); + } + } + } + + private Bidi getBidi() { + String paragraph = getTextAtCaret(); + int flags = (getControl().getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) ? + Bidi.DIRECTION_RIGHT_TO_LEFT : + Bidi.DIRECTION_LEFT_TO_RIGHT; + return new Bidi(paragraph, flags); + } + + private String getTextAtCaret() { + RichTextArea control = getControl(); + TextPos p = control.getCaretPosition(); + if (p != null) { + String s = control.getPlainText(p.index()); + if (s != null) { + return s; + } + } + return ""; + } + + private boolean isMixed() { + return getBidi().isMixed(); + } + + private boolean isRTLText() { + Bidi bidi = getBidi(); + return ( + bidi.isRightToLeft() || + (isMixed() && getControl().getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) + ); + } + + public void deleteWordNextBeg() { + deleteIgnoreSelection(this::nextWordBeg); + } + + public void deleteWordNextEnd() { + deleteIgnoreSelection(this::nextWordEnd); + } + + public void deleteWordPrevious() { + deleteIgnoreSelection(this::previousWord); + } + + private void deleteIgnoreSelection(BiFunction getter) { + RichTextArea control = getControl(); + if (control.canEdit()) { + TextPos caret = control.getCaretPosition(); + if (caret != null) { + TextPos p = getter.apply(control, caret); + if (p != null) { + control.clearSelection(); + clearPhantomX(); + p = control.replaceText(caret, p, "", true); + control.select(p); + } + } + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextAreaSkinHelper.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextAreaSkinHelper.java new file mode 100644 index 00000000000..6846e45b50a --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextAreaSkinHelper.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import javafx.scene.control.Skin; +import com.sun.javafx.util.Utils; +import com.sun.jfx.incubator.scene.control.rich.util.ListenerHelper; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.skin.RichTextAreaSkin; + +/** + * Manages RichTextAreaSkin Accessor. + */ +public class RichTextAreaSkinHelper { + public interface Accessor { + public VFlow getVFlow(Skin skin); + public ListenerHelper getListenerHelper(Skin skin); + } + + static { + Utils.forceInit(RichTextAreaSkin.class); + } + + private static Accessor accessor; + + public static void setAccessor(Accessor a) { + if (accessor != null) { + throw new IllegalStateException(); + } + accessor = a; + } + + public static VFlow getVFlow(RichTextArea t) { + var skin = t.getSkin(); + return accessor.getVFlow(skin); + } + + public static ListenerHelper getListenerHelper(RichTextAreaSkin skin) { + return accessor.getListenerHelper(skin); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextFormatHandlerHelper.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextFormatHandlerHelper.java new file mode 100644 index 00000000000..104f0e0b65b --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RichTextFormatHandlerHelper.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.Writer; +import com.sun.javafx.util.Utils; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.model.RichTextFormatHandler; +import jfx.incubator.scene.control.rich.model.StyledOutput; + +public class RichTextFormatHandlerHelper { + public interface Accessor { + public StyledOutput createStyledOutput(RichTextFormatHandler h, StyleResolver r, Writer wr); + } + + static { + Utils.forceInit(RichTextFormatHandler.class); + } + + private static Accessor accessor; + + public static void setAccessor(Accessor a) { + if (accessor != null) { + throw new IllegalStateException(); + } + accessor = a; + } + + public static StyledOutput createStyledOutput(RichTextFormatHandler h, StyleResolver r, Writer wr) { + return accessor.createStyledOutput(h, r, wr); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RtfStyledOutput.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RtfStyledOutput.java new file mode 100644 index 00000000000..dc5d77e5ffe --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/RtfStyledOutput.java @@ -0,0 +1,556 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This implementation is borrowed from +// https://github.com/andy-goryachev/FxTextEditor/blob/master/src/goryachev/fxtexteditor/internal/rtf/RtfWriter.java +// with permission from the author. + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import javafx.scene.Node; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledOutput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +/** + * StyledOutput which generates RTF. + * + * RTF 1.5 Spec: + * https://www.biblioscape.com/rtf15_spec.htm + */ +public class RtfStyledOutput implements StyledOutput { + private final LookupTable colorTable = new LookupTable<>(Color.BLACK); + private final LookupTable fontTable = new LookupTable<>("system"); + private final StyleResolver resolver; + private final Writer writer; + private boolean startOfLine = true; + private StyleAttrs prevStyle; + private Color color; + private Color background; + private boolean bold; + private boolean italic; + private boolean under; + private boolean strike; + private String fontFamily; + private Double fontSize; + + public RtfStyledOutput(StyleResolver r, Writer wr) { + this.resolver = new CachingStyleResolver(r); + this.writer = wr; + } + + public StyledOutput firstPassBuilder() { + return new StyledOutput() { + @Override + public void append(StyledSegment seg) throws IOException { + switch (seg.getType()) { + case PARAGRAPH_ATTRIBUTES: + // TODO + break; + case TEXT: + StyleAttrs a = seg.getStyleAttrs(resolver); + if (a != null) { + // colors + Color c = getTextColor(a); + colorTable.add(c); + + // TODO background color + // c = mixBackground(st.getBackgroundColor()); + // if (c != null) { + // colorTable.add(c); + // } + + // TODO font table + String family = a.getFontFamily(); + if (family != null) { + fontTable.add(family); + } + } + break; + } + } + + @Override + public void flush() throws IOException { + } + + @Override + public void close() throws IOException { + } + }; + } + + // \fnil Unknown or default fonts (the default) + // \froman Roman, proportionally spaced serif fonts Times New Roman, Palatino + // \fswiss Swiss, proportionally spaced sans serif fonts Arial + // \fmodern Fixed-pitch serif and sans serif fonts Courier New, Pica + // \fscript Script fonts Cursive + // \fdecor Decorative fonts Old English, ITC Zapf Chancery + // \ftech Technical, symbol, and mathematical fonts Symbol + // \fbidi Arabic, Hebrew, or other bidirectional font Miriam + private String lookupFontFamily(String name) { + try { + switch (name.toLowerCase()) { + case "monospaced": + return "\\fmodern Courier New"; + case "system": + case "sans-serif": + return "\\fswiss Helvetica"; + case "serif": + return "\\froman Times New Roman"; + case "cursive": + return "\\fscript Brush Script"; + case "fantasy": + return "\\fdecor ITC Zapf Chancery"; + } + } catch (Exception e) { + } + return null; + } + + public void writePrologue() throws IOException { + // preamble + write("{\\rtf1\\ansi\\ansicpg1252\\uc1\\sl0\\sb0\\sa0\\deff0"); + + // font table + write("{\\fonttbl"); + int ix = 0; + for (String name : fontTable.getItems()) { + String fam = lookupFontFamily(name); + + write("\\f"); + write(String.valueOf(ix++)); + if (fam == null) { + write("\\fnil"); + write(" "); + write(name); + } else { + write(fam); + } + write(";"); + } + write("}\r\n"); + + // color table + write("{\\colortbl ;"); + for (Color c : colorTable.getItems()) { + write("\\red"); + write(toInt255(c.getRed())); + write("\\green"); + write(toInt255(c.getGreen())); + write("\\blue"); + write(toInt255(c.getBlue())); + write(";"); + } + write("}\r\n"); + + // TODO \deftab720 Default tab width in twips (the default is 720). a twip is one-twentieth of a point + } + + @Override + public void append(StyledSegment seg) throws IOException { + switch (seg.getType()) { + case LINE_BREAK: + writeEndOfLine(); + writeNewLine(); + break; + case PARAGRAPH_ATTRIBUTES: + // TODO + break; + case REGION: + Node n = seg.getParagraphNodeGenerator().get(); + writeParagraph(n); + writeNewLine(); + break; + case TEXT: + writeTextSegment(seg); + break; + } + } + + public void writeEpilogue() throws IOException { + writeEndOfLine(); + write("\r\n}\r\n"); + } + + private void writeEndOfLine() throws IOException { + if (color != null) { + write("\\cf0 "); + color = null; + } + + if (background != null) { + write("\\highlight0 "); + background = null; + } + + if (bold) { + write("\\b0 "); + bold = false; + } + + if (italic) { + write("\\i0 "); + italic = false; + } + + if (under) { + write("\\ul0 "); + under = false; + } + + if (strike) { + write("\\strike0 "); + strike = false; + } + } + + private void writeNewLine() throws IOException { + write("\\par\r\n"); + startOfLine = true; + } + + @SuppressWarnings("null") // see L280 + private void writeTextSegment(StyledSegment seg) throws IOException { + checkCancelled(); + + if (startOfLine) { + // first line indent 0, left aligned + write("\\fi0\\ql "); + prevStyle = null; + + startOfLine = false; + } + + StyleAttrs a = seg.getStyleAttrs(resolver); + + if (RichUtils.notEquals(a, prevStyle) || RichUtils.notEquals(getTextColor(a), getTextColor(prevStyle))) { + Color col; + Color bg; + boolean bld; + boolean ita; + boolean und; + boolean str; + String fam; + Double fsize; + + if (a == null) { + col = null; + bg = null; + bld = false; + ita = false; + und = false; + str = false; + fam = null; + fsize = null; + } else { + col = getTextColor(a); + bg = null; // TODO mixBackground(st.getBackgroundColor()); + bld = a.isBold(); + ita = a.isItalic(); + und = a.isUnderline(); + str = a.isStrikeThrough(); + fam = a.getFontFamily(); + fsize = a.getFontSize(); + } + + prevStyle = a; + + // emit changes + + if (RichUtils.notEquals(fontFamily, fam)) { + int ix = fontTable.getIndexFor(fam); + write("\\f"); + write(String.valueOf(ix)); + + fontFamily = fam; + } + + if (RichUtils.notEquals(fontSize, fsize)) { + write("\\fs"); + // twice the points + double fs = (fsize == null) ? 24.0 : (fsize * 2.0); + write(String.valueOf((int)Math.round(fs))); + fontSize = fsize; + } + + if (RichUtils.notEquals(col, color)) { + if (col == null) { + write("\\cf0 "); + } else { + int ix = colorTable.getIndexFor(col); + if (ix > 0) { + ix++; + } + + write("\\cf"); + write(String.valueOf(ix)); + write(" "); + } + + color = col; + } + + if (RichUtils.notEquals(bg, background)) { + if (bg == null) { + write("\\highlight0 "); + } else { + int ix = colorTable.getIndexFor(bg); + if (ix > 0) { + ix++; + } + write("\\highlight"); + write(String.valueOf(ix)); + write(" "); + } + + background = bg; + } + + if (bld != bold) { + write(bld ? "\\b " : "\\b0 "); + bold = bld; + } + + if (ita != italic) { + write(ita ? "\\i " : "\\i0 "); + italic = ita; + } + + if (und != under) { + write(und ? "\\ul " : "\\ul0 "); + under = und; + } + + if (str != strike) { + write(str ? "\\strike " : "\\strike0 "); + strike = str; + } + } + + String text = seg.getText(); + String encoded = encode(text); + write(encoded); + } + + // TODO does not seem to work on Mac + private void writeParagraph(Node n) throws IOException { + WritableImage im = resolver.snapshot(n); + byte[] bytes = RichUtils.writePNG(im); + int w = (int)im.getWidth(); + int h = (int)im.getHeight(); + + write("{\\*\\shppict {\\pict \\pngblip"); + write("\\picscalex100\\picscaley100\\piccropl10\\piccropr0\\piccropt0\\piccropb0"); + write("\\picw"); + write(String.valueOf(w)); + write("\\pich"); + write(String.valueOf(h)); + write("\\picwgoal"); + // let's try to default to 6". 72 * 6 * 2 = 864 + int wgoal = 864; + write(String.valueOf(wgoal)); + int hgoal = h * wgoal / w; + write("\\pichgoal"); + write(String.valueOf(hgoal)); + write("\r\n"); + // There is no set maximum line length for an RTF file. + StringBuilder sb = new StringBuilder(2); + for (int i = 0; i < bytes.length; i++) { + byte b = bytes[i]; + hex2(sb, b); + write(sb.toString()); + if ((i % 80) == 79) { + write("\r\n"); + } + } + write("\r\n}}\r\n"); + } + + private static void hex2(StringBuilder sb, byte b) { + sb.setLength(0); + String hex = "0123456789abcdef"; + sb.append(hex.charAt((b >> 4) & 0x0f)); + sb.append(hex.charAt(b & 0x0f)); + } + + // TODO unit test! + private static String encode(String text) { + if (text == null) { + return ""; + } + + int ix = indexOfSpecialChar(text); + if (ix < 0) { + return text; + } + + int len = text.length(); + StringBuilder sb = new StringBuilder(len + 32); + sb.append(text, 0, ix); + + for (int i = ix; i < len; i++) { + char c = text.charAt(i); + if (c < 0x20) { + switch (c) { + case '\n': + case '\r': + break; + case '\t': + sb.append(c); + break; + } + } else if (c < 0x80) { + switch (c) { + case '\\': + sb.append("\\\\"); + break; + case '{': + sb.append("\\{"); + break; + case '}': + sb.append("\\}"); + break; + default: + sb.append(c); + break; + } + } else { + sb.append("\\u"); + sb.append(String.valueOf((short)c)); + sb.append("?"); + } + } + + return sb.toString(); + } + + private static int indexOfSpecialChar(String text) { + int len = text.length(); + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + if (c < 0x20) { + switch (c) { + case '\t': + continue; + default: + return i; + } + } else if (c < 0x80) { + switch (c) { + case '\\': + case '{': + case '}': + return i; + default: + continue; + } + } else { + return i; + } + } + return -1; + } + + private static String toInt255(double x) { + int v = (int)Math.round(255 * x); + if (v < 0) { + v = 0; + } else if (v > 255) { + v = 255; + } + return String.valueOf(v); + } + + private static void checkCancelled() throws IOException { + // check if interrupted + if (Thread.currentThread().isInterrupted()) { + // don't want to have it as a checked exception... may be throws Exception? + throw new IOException(new InterruptedException()); + } + + // TODO check if nearly out of memory + } + + private void write(String s) throws IOException { + writer.write(s); + } + + @Override + public void flush() throws IOException { + writer.flush(); + } + + @Override + public void close() throws IOException { + writer.close(); + } + + private static Color getTextColor(StyleAttrs a) { + Color c = a.getTextColor(); + return c == null ? Color.BLACK : c; + } + + /** RTF is unable to specify colors inline it seems, needs a color lookup table */ + protected static class LookupTable { + private final ArrayList items = new ArrayList<>(); + private final HashMap indexes = new HashMap<>(); + + public LookupTable(T initValue) { + if (initValue != null) { + add(initValue); + } + } + + public void add(T item) { + if (!indexes.containsKey(item)) { + Integer ix = Integer.valueOf(items.size()); + items.add(item); + indexes.put(item, ix); + } + } + + /** returns index or 0 if not found */ + public int getIndexFor(T c) { + Integer ix = indexes.get(c); + if (ix == null) { + return 0; + } + return ix.intValue(); + } + + public List getItems() { + return items; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SegmentStyledInput.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SegmentStyledInput.java new file mode 100644 index 00000000000..8e83500c0e8 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SegmentStyledInput.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import java.util.List; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +public class SegmentStyledInput implements StyledInput { + private final StyledSegment[] segments; + private int index; + + public SegmentStyledInput(StyledSegment[] segments) { + this.segments = segments; + } + + @Override + public StyledSegment nextSegment() { + if (index < segments.length) { + return segments[index++]; + } + return null; + } + + public static SegmentStyledInput of(List segments) { + StyledSegment[] ss = segments.toArray(new StyledSegment[segments.size()]); + return new SegmentStyledInput(ss); + } + + @Override + public void close() throws IOException { + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SegmentStyledOutput.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SegmentStyledOutput.java new file mode 100644 index 00000000000..9400f63b6ec --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SegmentStyledOutput.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import java.util.ArrayList; +import jfx.incubator.scene.control.rich.model.StyledOutput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +/** + * This StyledOutput simply collects StyledSegments in a list. + */ +public class SegmentStyledOutput implements StyledOutput { + private ArrayList segments; + + public SegmentStyledOutput(int initialCapacity) { + segments = new ArrayList<>(initialCapacity); + } + + @Override + public void append(StyledSegment s) throws IOException { + segments.add(s); + } + + @Override + public void flush() throws IOException { + } + + @Override + public void close() throws IOException { + } + + public StyledSegment[] getSegments() { + return segments.toArray(new StyledSegment[segments.size()]); + } +} \ No newline at end of file diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SelInfo.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SelInfo.java new file mode 100644 index 00000000000..35d3f024528 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SelInfo.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * A utility class to help deal with anchor and caret positions. + */ +public class SelInfo { + private final RichTextArea control; + private final TextPos caret; + private final TextPos anchor; + private final boolean caretAtMin; + + public SelInfo(RichTextArea control, TextPos caret, TextPos anchor, boolean caretAtMin) { + this.control = control; + this.caret = caret; + this.anchor = anchor; + this.caretAtMin = caretAtMin; + } + + public static SelInfo get(RichTextArea control) { + if (control == null) { + return null; + } + TextPos ca = control.getCaretPosition(); + if (ca == null) { + return null; + } + TextPos an = control.getAnchorPosition(); + if (an == null) { + an = ca; + } + boolean atMin = (ca.compareTo(an) <= 0); + return new SelInfo(control, ca, an, atMin); + } + + public TextPos getMin() { + return caretAtMin ? caret : anchor; + } + + public TextPos getMax() { + return caretAtMin ? anchor : caret; + } + + public TextPos getCaret() { + return caret; + } + + public TextPos getAnchor() { + return anchor; + } + + public boolean hasSelection() { + return caret.compareTo(anchor) != 0; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SelectionHelper.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SelectionHelper.java new file mode 100644 index 00000000000..cef4117fd42 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/SelectionHelper.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor +// Copyright © 2017-2023 Andy Goryachev + +package com.sun.jfx.incubator.scene.control.rich; + +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.PathElement; + +/** + * Selection Helper encapsulates the logic required to generate selection shapes. + * + * The goal is to find out which shapes correspond to the top-most and bottom-most + * text rows (in the presence of wrapping). These shapes (#) should be added to selection as is. + * Any space in between (x) would generate a single rectangular block that fills the + * width of the container. Additional shapes (#) will be added when necessary to make + * the selection appear contiguious. These shapes are positioned to the left or to the right + * of the selected text depending on the direction of text. + * + * TODO RTL text + *
+ * ----***--***####
+ * xxxxxxxxxxxxxxxx
+ * xxxxxxxxxxxxxxxx
+ * ####**----------
+ * 
+ * TODO this class can be static because everything happens in the FX app thread. + */ +public class SelectionHelper { + private final FxPathBuilder pathBuilder; + private final double left; + private final double right; + private double topUp = Double.POSITIVE_INFINITY; + private double topDn = Double.POSITIVE_INFINITY; + private double topLeft = Double.POSITIVE_INFINITY; + private double topRight = Double.NEGATIVE_INFINITY; + private double bottomUp = Double.NEGATIVE_INFINITY; + private double bottomDn = Double.NEGATIVE_INFINITY; + private double bottomLeft = Double.POSITIVE_INFINITY; + private double bottomRight = Double.NEGATIVE_INFINITY; + private static final double EPSILON = 0.001; // float point arithmetic is inexact + + public SelectionHelper(FxPathBuilder b, double left, double right) { + this.pathBuilder = b; + this.left = left; + this.right = right; + } + + @Override + public String toString() { + return "topUp=" + topUp + " topDn=" + topDn + " botUp=" + bottomUp + " botDn=" + bottomDn; + } + + @FunctionalInterface + protected interface PathHandler { + public void processPoint(double x, double y); + } + + private void process(PathElement[] elements, PathHandler h) { + for (PathElement em : elements) { + if (em instanceof LineTo p) { + h.processPoint(p.getX(), p.getY()); + } else if (em instanceof MoveTo p) { + h.processPoint(p.getX(), p.getY()); + } else { + throw new Error("?" + em); + } + } + } + + private void generateMiddle(boolean topLTR, boolean bottomLTR, double lineSpacing) { + if (Double.isNaN(topUp)) { + return; + } + + double td = topDn + lineSpacing; + double bd = bottomDn + lineSpacing; + + // only if the middle exists + if (bottomUp > topDn) { + if (topLTR) { + pathBuilder.moveto(topRight, topUp); + pathBuilder.lineto(right, topUp); + pathBuilder.lineto(right, td); + pathBuilder.lineto(topRight, td); + pathBuilder.lineto(topRight, topUp); + } else { + // TODO + } + + pathBuilder.moveto(left, td); + pathBuilder.lineto(right, td); + pathBuilder.lineto(right, bottomUp); + pathBuilder.lineto(left, bottomUp); + pathBuilder.lineto(left, td); + + // trailer + + if (bottomLTR) { + pathBuilder.moveto(left, bottomUp); + pathBuilder.lineto(bottomLeft, bottomUp); + pathBuilder.lineto(bottomLeft, bd); + pathBuilder.lineto(left, bd); + pathBuilder.lineto(left, bottomUp); + } else { + // TODO + } + } + } + + private boolean isNear(double a, double b) { + return Math.abs(a - b) < EPSILON; + } + + private void determineTopYLimits(double x, double y) { + if (y < topUp) { + topUp = y; + } + } + + private void determineTopXLimits(double x, double y) { + if (isNear(y, topUp)) { + if (x < topLeft) { + topLeft = x; + } + + if (x > topRight) { + topRight = x; + } + } else { + if (y < topDn) { + topDn = y; + } + } + } + + private void determineBottomYLimits(double x, double y) { + if (y > bottomDn) { + bottomDn = y; + } + } + + private void determineBottomXLimits(double x, double y) { + if (isNear(y, bottomDn)) { + if (x < bottomLeft) { + bottomLeft = x; + } + + if (x > bottomRight) { + bottomRight = x; + } + } else { + if (y > bottomUp) { + bottomUp = y; + } + } + } + + /** adjusts for line spacing and left padding */ + private void adjust(PathElement[] elements, double px, double py, double dx, double dy) { + for (PathElement em : elements) { + if (em instanceof LineTo p) { + double x = p.getX(); + if (isNear(x, px)) { + //p.setX(x - dx); + } + + double y = p.getY(); + if (isNear(y, py)) { + p.setY(y + dy); + } + } else if (em instanceof MoveTo p) { + double x = p.getX(); + if (isNear(x, px)) { + //p.setX(x - dx); + } + + double y = p.getY(); + if (isNear(y, py)) { + p.setY(y + dy); + } + } + } + } + + public void generate( + PathElement[] top, + PathElement[] bottom, + boolean topLTR, + boolean bottomLTR, + double leftPadding, + double lineSpacing + ) { + process(top, this::determineTopYLimits); + process(top, this::determineTopXLimits); + + if (bottom == null) { + // TODO special handling when outside of visible area + + adjust(top, topLeft, topDn, leftPadding, lineSpacing); + pathBuilder.addAll(top); + } else { + process(bottom, this::determineBottomYLimits); + process(bottom, this::determineBottomXLimits); + + adjust(top, topLeft, topDn, leftPadding, lineSpacing); + adjust(bottom, bottomLeft, bottomDn, leftPadding, lineSpacing); + +// D.p("top", dump(top), "bottom", dump(btm)); // FIX +// D.p(" top: y=" + r(topUp) + ".." + r(topDn) + " x=" + r(topLeft) + ".." + r(topRight)); +// D.p(" bot: y=" + r(bottomUp) + ".." + r(bottomDn) + " x=" + r(bottomLeft) + ".." + r(bottomRight)); + + pathBuilder.addAll(top); + generateMiddle(topLTR, bottomLTR, lineSpacing); + pathBuilder.addAll(bottom); + } + } +} \ No newline at end of file diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/StringBuilderStyledOutput.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/StringBuilderStyledOutput.java new file mode 100644 index 00000000000..3a3bc19e6fe --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/StringBuilderStyledOutput.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import jfx.incubator.scene.control.rich.model.StyledOutput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +public class StringBuilderStyledOutput implements StyledOutput { + private final StringBuilder sb; + private String newline = System.getProperty("line.separator"); + + public StringBuilderStyledOutput(int initialCapacity) { + sb = new StringBuilder(initialCapacity); + } + + public StringBuilderStyledOutput() { + this(1024); + } + + public void setLineSeparator(String s) { + newline = s; + } + + @Override + public void append(StyledSegment seg) { + switch (seg.getType()) { + case LINE_BREAK: + sb.append(newline); + break; + case TEXT: + String text = seg.getText(); + sb.append(text); + break; + } + } + + @Override + public String toString() { + return sb.toString(); + } + + @Override + public void flush() throws IOException { + } + + @Override + public void close() throws IOException { + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/StringStyledInput.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/StringStyledInput.java new file mode 100644 index 00000000000..cb0542d7bfb --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/StringStyledInput.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +public class StringStyledInput implements StyledInput { + private final String text; + private final StyleAttrs attrs; + private int offset; + + // TODO check for illegal chars (<0x20 except for \r \n \t) + public StringStyledInput(String text, StyleAttrs a) { + this.text = (text == null ? "" : text); + this.attrs = a; + } + + @Override + public StyledSegment nextSegment() { + if (offset < text.length()) { + int c = text.charAt(offset); + // is it a line break;? + switch(c) { + case '\n': + offset++; + return StyledSegment.LINE_BREAK; + case '\r': + c = charAt(++offset); + switch(c) { + case '\n': + offset++; + break; + } + return StyledSegment.LINE_BREAK; + } + + int ix = indexOfLineBreak(offset); + if (ix < 0) { + String s = text.substring(offset); + offset = text.length(); + return StyledSegment.of(s, attrs); + } else { + String s = text.substring(offset, ix); + offset = ix; + return StyledSegment.of(s, attrs); + } + } + return null; + } + + private int charAt(int ix) { + if (ix < text.length()) { + return text.charAt(ix); + } + return -1; + } + + private int indexOfLineBreak(int start) { + int len = text.length(); + for(int i=start; i + * Typically, a TextCell contains a TextFlow with styled text (and possibly inline Nodes). + * It is also possible to create a TextCell containing a single Region (which can be a Node of any complexity, + * even including a different instance of RichTextArea). These Regions will be presented as is, and, + * for editable models, would not be editable via the RichTextArea mechanisms. + *

+ * Each visible TextCell will be resized horizontally to fill the available width and then resized vertically + * according to its preferred size for that width. + */ +public final class TextCell extends BorderPane { + private final int index; + private final Region content; + private double height; + private double y; + + /** + * Creates a text cell with the specified {@code Region} as its content. + * @param index paragraph index + * @param content non-null content + */ + public TextCell(int index, Region content) { + Objects.nonNull(content); + this.index = index; + this.content = content; + setManaged(false); + setCenter(content); + } + + /** + * Creates a text cell with {@link TextFlow} as its content. + * @param index paragraph index + */ + public TextCell(int index) { + this(index, textFlow()); + } + + private static TextFlow textFlow() { + TextFlow t = new TextFlow(); + t.setMinHeight(0.0); // speeds up the layout + return t; + } + + /** + * Returns the content of this cell. + * @return the content Region + */ + public final Region getContent() { + return content; + } + + /** + * Adds a node to the text flow. + * @param node the node to add + */ + public void add(Node node) { + flow().getChildren().add(node); + } + + /** + * Returns the model index for this text cell. + * @return model index (>=0) + */ + public final int getIndex() { + return index; + } + + /** + * Returns the length of text in this cell. A cell containing a non-text content will return 0. + * @return the text length + */ + public int getTextLength() { + if (content instanceof TextFlow f) { + return RichUtils.getTextLength(f); + } + return 0; + } + + private TextFlow flow() { + if(content instanceof TextFlow f) { + return f; + } else { + throw new IllegalArgumentException("Not a TextFlow: " + content.getClass()); + } + } + + /** sets cell position along the y axis of this cell in VFlow coordinates */ + public void setPosition(double y, double height) { + this.y = y; + this.height = height; + } + + public double getCellHeight() { + return height; + } + + public double getY() { + return y; + } + + public void addBoxOutline(FxPathBuilder b, double x, double w, double h) { + double y0 = getLayoutY(); + double y1 = y0 + h; + + b.moveto(x, y0); + b.lineto(w, y0); + b.lineto(w, y1); + b.lineto(x, y1); + b.lineto(x, y0); + } + + /** + * Returns the {@code PathElement} array for the caret at the given character index and bias, + * translated to the {@code target} frame of reference. + * + * @param target the Region that provides the target frame of reference + * @param charIndex the character index + * @param leading the character bias + * @param dx the additional X offset + * @param dy the additional Y offset + * @return the array of path elements translated to the target coordinates + */ + public PathElement[] getCaretShape(Region target, int charIndex, boolean leading, double dx, double dy) { + PathElement[] p; + if (content instanceof TextFlow f) { + p = f.caretShape(charIndex, leading); + if (p.length == 2) { + PathElement p0 = p[0]; + PathElement p1 = p[1]; + if ((p0 instanceof MoveTo m0) && (p1 instanceof LineTo m1)) { + if (Math.abs(m0.getY() - m1.getY()) < 0.01) { + double x = m0.getX(); + double y = m0.getY(); + // empty line generates a single dot shape, not what we need + // using text flow height to get us a line caret shape + p[1] = new LineTo(x, y + f.getHeight()); + } + } + } + } else { + p = new PathElement[] { + new MoveTo(0.0, 0.0), + new LineTo(0.0, content.getHeight()) + }; + } + return RichUtils.translatePath(target, content, p, dx, dy); + } + + /** + * Returns the {@code PathElement} array for the range shape, + * translated to the {@code target} frame of reference. + * + * @param target the Region that provides the target frame of reference + * @param start the start offset + * @param end the end offset + * @param dx the additional X offset + * @param dy the additional Y offset + * @return the array of path elements translated to the target coordinates + */ + public PathElement[] getRangeShape(Region target, int start, int end, double dx, double dy) { + PathElement[] p; + if (content instanceof TextFlow f) { + p = f.rangeShape(start, end); + if ((p == null) || (p.length == 0)) { + p = new PathElement[] { + new MoveTo(0.0, 0.0), + new LineTo(0.0, f.getHeight()) + }; + } + } else { + double w = getWidth(); + double h = getHeight(); + + p = new PathElement[] { + new MoveTo(0.0, 0.0), + new LineTo(w, 0.0), + new LineTo(w, h), + new LineTo(0.0, h), + new LineTo(0.0, 0.0) + }; + } + return RichUtils.translatePath(target, content, p, dx, dy); + } + + /** + * Highlights the specified text range. + * @param start start offset for the range + * @param end end offset for the range + * @param color highlight color + */ + public void addHighlight(int start, int end, Color color) { + HighlightShape.addTo(content, HighlightShape.Type.HIGHLIGHT, start, end, color); + } + + /** + * Highlights the specified text range, using style names. + * @param start start offset for the range + * @param end end offset for the range + * @param styles CSS style names + */ + public void addHighlight(int start, int end, String... styles) { + HighlightShape.addTo(content, HighlightShape.Type.HIGHLIGHT, start, end, styles); + } + + /** + * Underlines the specified text range using squiggly line (as typically used by a spell checker). + * @param start start offset for the range + * @param end end offset for the range + * @param color highlight color + */ + public void addSquiggly(int start, int end, Color color) { + HighlightShape.addTo(content, HighlightShape.Type.SQUIGGLY, start, end, color); + } + + /** + * Underlines the specified text range using squiggly line (as typically used by a spell checker), + * using style names. + * @param start start offset for the range + * @param end end offset for the range + * @param styles CSS style names + */ + public void addSquiggly(int start, int end, String... styles) { + HighlightShape.addTo(content, HighlightShape.Type.SQUIGGLY, start, end, styles); + } + + /** + * Sets the bullet decoration by adding a Label with the specified character. + * @param bullet + */ + public void setBullet(String bullet) { + Label b = new Label(bullet); + b.setAlignment(Pos.TOP_CENTER); + // TODO get some attributes from the first text segment - font? color? or use default paragraph attrs? + setLeft(b); + } + + /** + * Returns line spacing if the content is a {@code TextFlow}, 0.0 otherwise. + * @return the line spacing + */ + public double getLineSpacing() { + if (content instanceof TextFlow f) { + return f.getLineSpacing(); + } + return 0.0; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/UndoableChange.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/UndoableChange.java new file mode 100644 index 00000000000..f7b16e70ed3 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/UndoableChange.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import java.io.IOException; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.StyledSegment; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * Represents an undo-able and redo-able change. + */ +public class UndoableChange { + private final StyledTextModel model; + private final TextPos start; + private final StyledSegment[] undo; + private StyledSegment[] redo; + private final TextPos endBefore; + private TextPos endAfter; + private UndoableChange prev; + private UndoableChange next; + + private UndoableChange(StyledTextModel model, TextPos start, TextPos end, StyledSegment[] undo) { + this.model = model; + this.start = start; + this.endBefore = end; + this.undo = undo; + } + + /** + * Creates an UndoableChange object. + * This method might return null if an error happened during creation, for example, if the model + * could not export the affected area as a sequence of StyledSegments. + *

+ * TODO perhaps it should throw an exception which will be handled by the control, in order to provide + * user feedback. + * @param model source model + * @param start start text position + * @param end end text position + * @throws IOException if the save point cannot be created + */ + public static UndoableChange create(StyledTextModel model, TextPos start, TextPos end) { + try { + SegmentStyledOutput out = new SegmentStyledOutput(128); + model.export(start, end, out); + StyledSegment[] ss = out.getSegments(); + return new UndoableChange(model, start, end, ss); + } catch (IOException e) { + // TODO log + return null; + } + } + + public static UndoableChange createHead() { + return new UndoableChange(null, null, null, null); + } + + @Override + public String toString() { + return + "UndoableChange{" + + "start=" + start + + ", endBefore=" + endBefore + + ", endAfter=" + endAfter; + } + + public void setEndAfter(TextPos p) { + endAfter = p; + } + + public void undo(StyleResolver resolver) throws IOException { + if (redo == null) { + // create redo + SegmentStyledOutput out = new SegmentStyledOutput(128); + model.export(start, endAfter, out); + redo = out.getSegments(); + } + + // undo + SegmentStyledInput in = new SegmentStyledInput(undo); + model.replace(resolver, start, endAfter, in, false); + } + + public void redo(StyleResolver resolver) throws IOException { + SegmentStyledInput in = new SegmentStyledInput(redo); + model.replace(resolver, start, endBefore, in, false); + } + + public UndoableChange getPrev() { + return prev; + } + + public void setPrev(UndoableChange ch) { + prev = ch; + } + + public UndoableChange getNext() { + return next; + } + + public void setNext(UndoableChange ch) { + next = ch; + } + + public TextPos[] getSelectionBefore() { + return new TextPos[] { + start, + endBefore + }; + } + + public TextPos[] getSelectionAfter() { + return new TextPos[] { + start, + endAfter + }; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/VFlow.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/VFlow.java new file mode 100644 index 00000000000..329179ba8a4 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/VFlow.java @@ -0,0 +1,1580 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package com.sun.jfx.incubator.scene.control.rich; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.animation.Animation; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.ReadOnlyProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.event.EventType; +import javafx.geometry.Bounds; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.NodeOrientation; +import javafx.geometry.VPos; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.ScrollBar; +import javafx.scene.image.WritableImage; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.Path; +import javafx.scene.shape.PathElement; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; +import javafx.util.Duration; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.SideDecorator; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.ContentChange; +import jfx.incubator.scene.control.rich.model.ParagraphDirection; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledSegment; +import jfx.incubator.scene.control.rich.model.StyledTextModel; +import jfx.incubator.scene.control.rich.skin.RichTextAreaSkin; + +/** + * Virtual text flow deals with TextCells, scroll bars, and conversion + * between the model and the screen coordinates. + */ +public class VFlow extends Pane implements StyleResolver, StyledTextModel.Listener { + private final RichTextAreaSkin skin; + private final RichTextArea control; + private final ScrollBar vscroll; + private final ScrollBar hscroll; + private final ClippedPane leftGutter; + private final ClippedPane rightGutter; + private final StackPane content; + private final ClippedPane flow; + private final Path caretPath; + private final Path caretLineHighlight; + private final Path selectionHighlight; + private final SimpleBooleanProperty caretVisible = new SimpleBooleanProperty(true); + private final SimpleBooleanProperty suppressBlink = new SimpleBooleanProperty(false); + private final SimpleDoubleProperty offsetX = new SimpleDoubleProperty(0.0); + private final ReadOnlyObjectWrapper origin = new ReadOnlyObjectWrapper(Origin.ZERO); + private final Timeline caretAnimation; + private final FastCache cellCache; + private CellArrangement arrangement; + private boolean dirty = true; + private FastCache leftCache; + private FastCache rightCache; + private boolean handleScrollEvents = true; + private boolean vsbPressed; + private double topPadding; + private double bottomPadding; + private double leftPadding; + private double rightPadding; + private double leftSide; + private double rightSide; + private boolean inReflow; + private double unwrappedContentWidth; + private static final Text measurer = makeMeasurer(); + private static final VFlowCellContext context = new VFlowCellContext(); + + public VFlow(RichTextAreaSkin skin, ScrollBar vscroll, ScrollBar hscroll) { + this.skin = skin; + this.control = skin.getSkinnable(); + this.vscroll = vscroll; + this.hscroll = hscroll; + + cellCache = new FastCache(Params.CELL_CACHE_SIZE); + + getStyleClass().add("vflow"); + + // TODO consider creating on demand + leftGutter = new ClippedPane("left-side"); + leftGutter.setManaged(false); + // TODO consider creating on demand + rightGutter = new ClippedPane("right-side"); + rightGutter.setManaged(false); + + content = new StackPane(); + content.getStyleClass().add("content"); + content.setManaged(false); + + flow = new ClippedPane("flow"); + flow.setManaged(true); + + caretPath = new Path(); + caretPath.getStyleClass().add("caret"); + caretPath.setManaged(false); + + caretLineHighlight = new Path(); + caretLineHighlight.getStyleClass().add("caret-line"); + caretLineHighlight.setManaged(false); + + selectionHighlight = new Path(); + selectionHighlight.getStyleClass().add("selection-highlight"); + selectionHighlight.setManaged(false); + + // make sure these are clipped by flow clip rectangle + bindClipRectangle(caretLineHighlight, flow); + bindClipRectangle(selectionHighlight, flow); + bindClipRectangle(caretPath, flow); + + // layout + content.getChildren().addAll(caretLineHighlight, selectionHighlight, flow, caretPath); + getChildren().addAll(content, leftGutter, rightGutter); + + caretAnimation = new Timeline(); + caretAnimation.setCycleCount(Animation.INDEFINITE); + + caretPath.visibleProperty().bind(new BooleanBinding() { + { + bind( + caretVisible, + control.displayCaretProperty(), + control.focusedProperty(), + control.disabledProperty(), + suppressBlink + ); + } + + @Override + protected boolean computeValue() { + return + (isCaretVisible() || suppressBlink.get()) && + control.isDisplayCaret() && + control.isFocused() && + (!control.isDisabled()); + } + }); + + // FIX contentWidth.addListener((p) -> updateHorizontalScrollBar()); + offsetX.addListener((p) -> updateHorizontalScrollBar()); + origin.addListener((p) -> handleOriginChange()); + widthProperty().addListener((p) -> handleWidthChange()); + + vscroll.addEventFilter(MouseEvent.ANY, this::handleVScrollMouseEvent); + + updateHorizontalScrollBar(); + handleOriginChange(); + } + + public void dispose() { + caretPath.visibleProperty().unbind(); + } + + public Pane getContentPane() { + return flow; + } + + private static Text makeMeasurer() { + Text t = new Text("8"); + t.setManaged(false); + return t; + } + + private void bindClipRectangle(Node target, Node source) { + Rectangle r = new Rectangle(); + target.setClip(r); + source.boundsInParentProperty().addListener((s,p,sb) -> { + Bounds b = target.parentToLocal(sb); + r.setX(b.getMinX()); + r.setY(b.getMinY()); + r.setWidth(b.getWidth()); + r.setHeight(b.getHeight()); + }); + Rectangle sr = (Rectangle)source.getClip(); + } + + public void handleModelChange() { + setUnwrappedContentWidth(0.0); + setOrigin(new Origin(0, -topPadding)); + setOffsetX(-leftPadding); + requestControlLayout(true); + control.select(TextPos.ZERO); + } + + /** width of the area available for text cells. */ + private double viewPortWidth() { + double w = getWidth() - leftSide - rightSide - snapSpaceX(Params.LAYOUT_FOCUS_BORDER) - snapSpaceX(Params.LAYOUT_FOCUS_BORDER); + if(w < 0.0) { + w = 0.0; + } + return snapSpaceX(w); + } + + /** height of the area available for text cells. */ + private double viewPortHeight() { + double h = getHeight() - snapSpaceX(Params.LAYOUT_FOCUS_BORDER) - snapSpaceX(Params.LAYOUT_FOCUS_BORDER); + if (h < 0.0) { + h = 0.0; + } + return h; + } + + public final void handleWrapText() { + if (control.isWrapText()) { + double w = viewPortWidth(); + setUnwrappedContentWidth(w); + } else { + setUnwrappedContentWidth(0.0); + } + setOffsetX(-leftPadding); + + updateHorizontalScrollBar(); + updateVerticalScrollBar(); + requestControlLayout(true); + } + + public void handleDecoratorChange() { + leftCache = updateSideCache(control.getLeftDecorator(), leftCache); + rightCache = updateSideCache(control.getRightDecorator(), rightCache); + requestControlLayout(false); + } + + private FastCache updateSideCache(SideDecorator decorator, FastCache cache) { + if (decorator == null) { + if (cache != null) { + cache.clear(); + } + } else { + if (cache == null) { + cache = new FastCache<>(Params.CELL_CACHE_SIZE); + } else { + cache.clear(); + } + } + return cache; + } + + public void invalidateLayout() { + cellCache.clear(); + requestLayout(); + updateHorizontalScrollBar(); + updateVerticalScrollBar(); + } + + public void handleContentPadding() { + updateContentPadding(); + + setOffsetX(-leftPadding); + + if (getOrigin().index() == 0) { + setOrigin(new Origin(0, -topPadding)); + } + + requestLayout(); + updateHorizontalScrollBar(); + updateVerticalScrollBar(); + } + + public void updateContentPadding() { + Insets m = control.getContentPadding(); + if (m == null) { + leftPadding = 0.0; + rightPadding = 0.0; + topPadding = 0.0; + bottomPadding = 0.0; + } else { + leftPadding = snapPositionX(m.getLeft()); + rightPadding = snapPositionX(m.getRight()); + topPadding = snapPositionY(m.getTop()); + bottomPadding = snapPositionY(m.getBottom()); + } + } + + public double leftPadding() { + return leftPadding; + } + + /** Location of the top left corner. */ + public final ReadOnlyProperty originProperty() { + return origin.getReadOnlyProperty(); + } + + public final Origin getOrigin() { + return origin.get(); + } + + private void setOrigin(Origin or) { + if (or == null) { + throw new NullPointerException(); + } + // prevent scrolling + if (control.isUseContentHeight() || ((or.index() == 0) && (or.offset() == 0))) { + or = new Origin(0, -topPadding); + } + origin.set(or); + } + + private void handleOriginChange() { + if (!inReflow) { + requestLayout(); + } + } + + public int topCellIndex() { + return getOrigin().index(); + } + + public double getOffsetX() { + return offsetX.get(); + } + + public void setOffsetX(double x) { + // prevent scrolling + if (control.isUseContentWidth()) { + x = -leftPadding; + } + offsetX.set(x); + } + + /** horizontal scroll offset */ + public DoubleProperty offsetXProperty() { + return offsetX; + } + + /** max width of all visible text cells (cells within the viewport), excluding content padding */ + private double unwrappedContentWidth() { + return unwrappedContentWidth; + } + + private void setUnwrappedContentWidth(double w) { + if (w < Params.LAYOUT_MIN_WIDTH) { + w = Params.LAYOUT_MIN_WIDTH; + } + unwrappedContentWidth = w; + } + + public void setCaretVisible(boolean on) { + caretVisible.set(on); + } + + public boolean isCaretVisible() { + return caretVisible.get(); + } + + /** reacts to width changes */ + protected void handleWidthChange() { + if (control.isWrapText()) { + double w = viewPortWidth(); + setUnwrappedContentWidth(w); + } else { + double w = getOffsetX() + flow.getWidth(); + double uw = arrangement.getUnwrappedWidth(); + if (uw > w) { + w = uw; + } + + // scroll horizontally when expanding beyond right boundary + double delta = unwrappedContentWidth() + rightPadding - getOffsetX() - viewPortWidth(); + if (delta < 0.0) { + double off = getOffsetX() + delta; + if (off > -leftPadding) { + setOffsetX(off); + } + } + + // TODO set visibility + updateHorizontalScrollBar(); + } + } + + public void handleSelectionChange() { + setSuppressBlink(true); + updateCaretAndSelection(); + scrollCaretToVisible(); + setSuppressBlink(false); + } + + public void updateCaretAndSelection() { + if (arrangement == null) { + removeCaretAndSelection(); + return; + } + + TextPos caret = control.getCaretPosition(); + if (caret == null) { + removeCaretAndSelection(); + return; + } + + TextPos anchor = control.getAnchorPosition(); + if (anchor == null) { + anchor = caret; + } + + // current line highlight + if (control.isHighlightCurrentParagraph()) { + FxPathBuilder b = new FxPathBuilder(); + createCurrentLineHighlight(b, caret); + caretLineHighlight.getElements().setAll(b.getPathElements()); + } else { + caretLineHighlight.getElements().clear(); + } + + // selection + FxPathBuilder b = new FxPathBuilder(); + createSelectionHighlight(b, anchor, caret); + selectionHighlight.getElements().setAll(b.getPathElements()); + selectionHighlight.setTranslateX(leftPadding); + + // caret + b = new FxPathBuilder(); + createCaretPath(b, caret); + caretPath.getElements().setAll(b.getPathElements()); + caretPath.setTranslateX(leftPadding); + } + + protected void removeCaretAndSelection() { + caretLineHighlight.getElements().clear(); + selectionHighlight.getElements().clear(); + caretPath.getElements().clear(); + } + + protected void createCaretPath(FxPathBuilder b, TextPos p) { + CaretInfo c = getCaretInfo(p); + if (c != null) { + b.addAll(c.path()); + } + } + + protected void createSelectionHighlight(FxPathBuilder b, TextPos start, TextPos end) { + // probably unnecessary + if ((start == null) || (end == null)) { + return; + } + + int eq = start.compareTo(end); + if (eq == 0) { + return; + } else if (eq > 0) { + TextPos p = start; + start = end; + end = p; + } + + int topCellIndex = topCellIndex(); + if (end.index() < topCellIndex) { + // selection is above visible area + return; + } else if (start.index() >= (topCellIndex + arrangement().getVisibleCellCount())) { + // selection is below visible area + return; + } + + // get selection shapes for top and bottom segments, + // translated to this VFlow coordinates. + PathElement[] top; + PathElement[] bottom; + if (start.index() == end.index()) { + top = getRangeShape(start.index(), start.offset(), end.offset()); + bottom = null; + } else { + top = getRangeShape(start.index(), start.offset(), -1); + if (top == null) { + top = getRangeTop(); + } + + bottom = getRangeShape(end.index(), 0, end.offset()); + if (bottom == null) { + bottom = getRangeBottom(); + } + } + + // generate shapes + double left = -leftPadding; + double right = unwrappedContentWidth() + leftPadding + rightPadding; + // TODO + boolean topLTR = true; + boolean bottomLTR = true; + + // FIX + double lineSpacing = 0.0; // this is a problem! + new SelectionHelper(b, left, right).generate(top, bottom, topLTR, bottomLTR, leftPadding, lineSpacing); + } + + protected void createCurrentLineHighlight(FxPathBuilder b, TextPos caret) { + int ix = caret.index(); + TextCell cell = arrangement().getVisibleCell(ix); + if (cell != null) { + double w; + if (control.isWrapText()) { + w = getWidth(); + } else { + w = Math.max(getWidth(), unwrappedContentWidth()); + } + cell.addBoxOutline(b, 0.0, snapPositionX(w), cell.getCellHeight()); + } + } + + /** uses vflow.content cooridinates */ + public TextPos getTextPosLocal(double localX, double localY) { + // convert to cell coordinates + double x = localX + getOffsetX(); + return arrangement().getTextPos(x, localY); + } + + /** in vflow.flow coordinates */ + // TODO vflow.flow? or content? + protected CaretInfo getCaretInfo(TextPos p) { + return arrangement().getCaretInfo(flow, getOffsetX() + leftPadding, p); + } + + /** returns caret sizing info using vflow.content coordinates, or null */ + public CaretInfo getCaretInfo() { + TextPos p = control.getCaretPosition(); + if (p == null) { + return null; // TODO check + } + return getCaretInfo(p); + } + + protected PathElement[] getRangeTop() { + double w = getWidth(); + return new PathElement[] { + new MoveTo(0, -1), + new LineTo(w, -1), + new LineTo(w, 0), + new LineTo(0, 0), + new LineTo(0, -1) + }; + } + + protected PathElement[] getRangeBottom() { + double w = getWidth(); + double h = getHeight(); + double h1 = h + 1.0; + + return new PathElement[] { + new MoveTo(0, h), + new LineTo(w, h), + new LineTo(w, h1), + new LineTo(0, h1), + new LineTo(0, h) + }; + } + + /** returns the shape if both ends are at the same line */ + protected PathElement[] getRangeShape(int line, int startOffset, int endOffset) { + TextCell cell = arrangement().getVisibleCell(line); + if (cell == null) { + return null; + } + + if (endOffset < 0) { + // FIX to the edge?? but beware of RTL + endOffset = cell.getTextLength(); + } + + Insets m = contentPadding(); + double dx = -m.getLeft(); + double dy = 0.0; + + PathElement[] pe; + if (startOffset == endOffset) { + // TODO handle split caret! + pe = cell.getCaretShape(flow, startOffset, true, dx, dy); + } else { + pe = cell.getRangeShape(flow, startOffset, endOffset, dx, dy); + } + return pe; + } + + public final void setSuppressBlink(boolean on) { + suppressBlink.set(on); + if (!on) { + updateRateRestartBlink(); + } + } + + public final void updateRateRestartBlink() { + Duration t2 = control.getCaretBlinkPeriod(); + Duration t1 = t2.divide(2.0); + + caretAnimation.stop(); + caretAnimation.getKeyFrames().setAll( + new KeyFrame(Duration.ZERO, (ev) -> setCaretVisible(true)), + new KeyFrame(t1, (ev) -> setCaretVisible(false)), + new KeyFrame(t2) + ); + caretAnimation.play(); + } + + public final int getParagraphCount() { + return control.getParagraphCount(); + } + + /** + * Returns control's content padding, always non-null. + * @return the content padding + */ + public final Insets contentPadding() { + Insets m = control.getContentPadding(); + return m == null ? Insets.EMPTY : m; + } + + private void handleVScrollMouseEvent(MouseEvent ev) { + EventType t = ev.getEventType(); + if (t == MouseEvent.MOUSE_PRESSED) { + vsbPressed = true; + } else if (t == MouseEvent.MOUSE_RELEASED) { + vsbPressed = false; + updateVerticalScrollBar(); + } + } + + /** updates VSB in response to change in height, layout, or offsetY */ + protected void updateVerticalScrollBar() { + double visible; + double val; + if (getParagraphCount() == 0) { + visible = 1.0; + val = 0.0; + } else { + CellArrangement ar = arrangement(); + double av = ar.averageHeight(); + double max = ar.estimatedMax(); + double h = getHeight(); + val = toScrollBarValue((topCellIndex() - ar.topCount()) * av + ar.topHeight(), h, max); + visible = h / max; + } + + handleScrollEvents = false; + + vscroll.setMin(0.0); + vscroll.setMax(1.0); + vscroll.setUnitIncrement(Params.SCROLL_BARS_UNIT_INCREMENT); + vscroll.setVisibleAmount(visible); + vscroll.setValue(val); + + handleScrollEvents = true; + } + + /** handles user moving the vertical scroll bar */ + public void handleVerticalScroll() { + if (handleScrollEvents) { + if (getParagraphCount() == 0) { + return; + } + + double max = vscroll.getMax(); + double val = vscroll.getValue(); + double visible = vscroll.getVisibleAmount(); + double pos = fromScrollBarValue(val, visible, max); // max is 1.0 + + Origin p = arrangement().fromAbsolutePosition(pos); + setOrigin(p); + } + } + + /** updates HSB in response to change in width, layout, or offsetX */ + protected void updateHorizontalScrollBar() { + boolean wrap = control.isWrapText(); + if (wrap) { + return; + } + + double max = unwrappedContentWidth() + leftPadding + rightPadding; + double w = flow.getWidth(); + double off = getOffsetX() + leftPadding; + double vis = w / max; + double val = toScrollBarValue(off, w, max); + + handleScrollEvents = false; + + hscroll.setMin(0.0); + hscroll.setMax(1.0); + hscroll.setUnitIncrement(Params.SCROLL_BARS_UNIT_INCREMENT); + hscroll.setVisibleAmount(vis); + hscroll.setValue(val); + + handleScrollEvents = true; + } + + /** handles user moving the horizontal scroll bar */ + public void handleHorizontalScroll() { + if (handleScrollEvents) { + if (arrangement == null) { + return; + } else if (control.isWrapText()) { + return; + } + + double max = unwrappedContentWidth() + leftPadding + rightPadding; + double visible = flow.getWidth(); + double val = hscroll.getValue(); + double off = fromScrollBarValue(val, visible, max) - leftPadding; + + setOffsetX(snapPositionX(off)); + // no need to recompute the flow + placeCells(); + updateCaretAndSelection(); + } + } + + /** + * javafx ScrollBar is weird in that the value has a range between [min,max] regardless of visible amount. + * this method generates the value ScrollBar expects by renormalizing it to a [min,max-visible] range, + * assuming min == 0. + */ + private static double toScrollBarValue(double val, double visible, double max) { + if (Math.abs(max - visible) < 1e-10) { + return 0.0; + } else { + return val / (max - visible); + } + } + + /** inverse of {@link #toScrollBarValue}, returns the scroll bar value that takes into account visible amount */ + private static double fromScrollBarValue(double val, double visible, double max) { + return val * (max - visible); + } + + public TextCell getCell(int modelIndex) { + TextCell cell = cellCache.get(modelIndex); + if (cell == null) { + RichParagraph rp = control.getModel().getParagraph(modelIndex); + cell = createTextCell(modelIndex, rp); + cellCache.add(cell.getIndex(), cell); + } + return cell; + } + + private TextCell createTextCell(int index, RichParagraph par) { + if(par == null) { + return null; + } + TextCell cell; + StyleAttrs pa = par.getParagraphAttributes(); + Supplier gen = par.getParagraphRegion(); + if (gen != null) { + // it's a paragraph node + Region content = gen.get(); + cell = new TextCell(index, content); + } else { + // it's a regular text cell + cell = new TextCell(index); + + // first line indent operates on TextCell and not its content + if (pa != null) { + Double firstLineIndent = pa.getFirstLineIndent(); + if (firstLineIndent != null) { + cell.add(new FirstLineIndentSpacer(firstLineIndent)); + } + } + + // highlights + List> highlights = RichParagraphHelper.getHighlights(par); + if (highlights != null) { + for (Consumer h : highlights) { + h.accept(cell); + } + } + + // segments + List segments = RichParagraphHelper.getSegments(par); + if ((segments == null) || segments.isEmpty()) { + // a bit of a hack: avoid TextCells with an empty TextFlow, + // otherwise it makes the caret collapse to a single point + cell.add(createTextNode("", StyleAttrs.EMPTY)); + } else { + for (StyledSegment seg : segments) { + switch (seg.getType()) { + case INLINE_NODE: + Node n = seg.getInlineNodeGenerator().get(); + cell.add(n); + break; + case TEXT: + String text = seg.getText(); + StyleAttrs a = seg.getStyleAttrs(this); + Text t = createTextNode(text, a); + cell.add(t); + break; + } + } + } + } + + if (pa == null) { + pa = StyleAttrs.EMPTY; + } else { + // these two attributes operate on TextCell instead of its content + String bullet = pa.getBullet(); + if (bullet != null) { + cell.setBullet(bullet); + } + + if (control.isWrapText()) { + ParagraphDirection d = pa.getParagraphDirection(); + if (d != null) { + switch (d) { + case LEFT_TO_RIGHT: + cell.setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); + break; + case RIGHT_TO_LEFT: + cell.setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); + break; + } + } + } + } + + context.reset(cell.getContent(), pa); + skin.applyStyles(context, pa, true); + context.apply(); + + return cell; + } + + private Text createTextNode(String text, StyleAttrs attrs) { + Text t = new Text(text); + context.reset(t, attrs); + skin.applyStyles(context, attrs, false); + context.apply(); + return t; + } + + private double computeSideWidth(SideDecorator d) { + if (d != null) { + double w = d.getPrefWidth(getWidth()); + if (w <= 0.0) { + int top = topCellIndex(); + Node n = d.getNode(top, true); + n.setManaged(false); + + flow.getChildren().add(n); + try { + n.applyCss(); + if (n instanceof Parent p) { + p.layout(); + } + w = n.prefWidth(-1); + } finally { + flow.getChildren().remove(n); + } + } + // introducing some granularity in order to avoid left boundary moving back and forth when scrolling + double granularity = 15; + w = (Math.round((w + 1.0) / granularity) + 1.0) * granularity; + return snapSizeX(w); + } + return 0.0; + } + + @Override + protected void layoutChildren() { + reflow(); + } + + protected void reflow() { + inReflow = true; + try { + // remove old nodes, if any + if (arrangement != null) { + arrangement.removeNodesFrom(flow); + arrangement = null; + } + + arrangement = new CellArrangement(this); + layoutCells(); + + checkForExcessiveWhitespaceAtTheEnd(); + updateCaretAndSelection(); + + // eliminate VSB during scrolling with a mouse + // the VSB will finally get updated on mouse released event + if (!vsbPressed) { + updateVerticalScrollBar(); + } + } finally { + dirty = false; + inReflow = false; + } + } + + /** returns a non-null layout, laying out cells if necessary */ + protected CellArrangement arrangement() { + if (!inReflow && dirty || (arrangement == null)) { + reflow(); + } + return arrangement; + } + + /** recomputes sliding window */ + protected void layoutCells() { + if (control.getModel() == null) { + leftGutter.setVisible(false); + rightGutter.setVisible(false); + return; + } + + double width = getWidth(); + if(width == 0.0) { + return; + } + + // sides + SideDecorator leftDecorator = control.getLeftDecorator(); + SideDecorator rightDecorator = control.getRightDecorator(); + leftSide = computeSideWidth(leftDecorator); + rightSide = computeSideWidth(rightDecorator); + + int paragraphCount = getParagraphCount(); + boolean useContentHeight = control.isUseContentHeight(); + boolean useContentWidth = control.isUseContentWidth(); + boolean wrap = control.isWrapText() && !useContentWidth; + double height = useContentHeight ? 0.0 : getHeight(); + + double forWidth; + double maxWidth; + if (wrap) { + forWidth = viewPortWidth(); + maxWidth = forWidth; + } else { + forWidth = -1.0; + maxWidth = Params.MAX_WIDTH_FOR_LAYOUT; + } + + double ytop = snapPositionY(-getOrigin().offset()); + double y = ytop; + double unwrappedWidth = 0.0; + double total = 0.0; + double margin = Params.SLIDING_WINDOW_EXTENT * height; + int topMarginCount = 0; + int bottomMarginCount = 0; + int count = 0; + boolean visible = true; + // TODO if topCount < marginCount, increase bottomCount correspondingly + // also, update Origin if layout hit the beginning/end of the document + + // populating visible part of the sliding window + bottom margin + int i = topCellIndex(); + for ( ; i < paragraphCount; i++) { + TextCell cell = getCell(i); + // TODO skip computation if layout width is the same + Region r = cell.getContent(); + flow.getChildren().add(cell); + cell.setMaxWidth(maxWidth); + cell.setMaxHeight(USE_COMPUTED_SIZE); + + cell.applyCss(); + cell.layout(); + + arrangement.addCell(cell); + + double h = cell.prefHeight(forWidth) + getLineSpacing(r); + h = snapSizeY(h); // is this right? or snap(y + h) - snap(y) ? + cell.setPosition(y, h/*, forWidth*/); + + if (!wrap) { + if (visible) { + double w = cell.prefWidth(-1); + if (w > unwrappedWidth) { + unwrappedWidth = w; + } + } + } + + y = snapPositionY(y + h); + total += h; + count++; + + if (useContentHeight) { + height = y; + if (y > Params.MAX_HEIGHT_SAFEGUARD) { + break; + } + } else { + // stop populating the bottom part of the sliding window + // when exceeded both pixel and line count margins + if (visible) { + if (y > height) { + topMarginCount = (int)Math.ceil(count * Params.SLIDING_WINDOW_EXTENT); + bottomMarginCount = count + topMarginCount; + arrangement.setVisibleCellCount(count); + visible = false; + } + } else { + // remove invisible cell from layout after sizing + flow.getChildren().remove(cell); + + if ((y > (height + margin)) && (count > bottomMarginCount)) { + break; + } + } + } + } + + // in case there are less paragraphs than can fit in the view + if (visible) { + arrangement.setVisibleCellCount(count); + } + + if (i == paragraphCount) { + y += bottomPadding; + } + + // populate side nodes + if (leftDecorator != null) { + if (leftCache == null) { + leftCache = updateSideCache(leftDecorator, null); + } + + for (i = 0; i < arrangement.getVisibleCellCount(); i++) { + TextCell cell = arrangement.getCellAt(i); + int ix = cell.getIndex(); + Node n = leftCache.get(ix); + if (n == null) { + n = leftDecorator.getNode(ix, false); + if (n != null) { + n.setManaged(false); + leftCache.add(ix, n); + } + } + if (n != null) { + arrangement.addLeftNode(i, n); + } + } + } + + if (rightDecorator != null) { + if (rightCache == null) { + rightCache = updateSideCache(rightDecorator, null); + } + + for (i = 0; i < arrangement.getVisibleCellCount(); i++) { + TextCell cell = arrangement.getCellAt(i); + int ix = cell.getIndex(); + Node n = rightCache.get(ix); + if (n == null) { + n = rightDecorator.getNode(cell.getIndex(), false); + if (n != null) { + n.setManaged(false); + rightCache.add(ix, n); + } + } + if (n != null) { + arrangement.addRightNode(i, n); + } + } + } + + unwrappedWidth = snapSizeX(unwrappedWidth); + + if(topCellIndex() > 0) { + total = Double.POSITIVE_INFINITY; + } + + arrangement.setBottomCount(count); + arrangement.setBottomHeight(y); + arrangement.setUnwrappedWidth(unwrappedWidth); + arrangement.setTotalHeight(total); + count = 0; + y = ytop; + + // populate top margin, going backwards from topCellIndex + // TODO populate more, if bottom ended prematurely + for (i = topCellIndex() - 1; i >= 0; i--) { + TextCell cell = getCell(i); + // TODO maybe skip computation if layout width is the same + Region r = cell.getContent(); + flow.getChildren().add(cell); + cell.setMaxWidth(maxWidth); + cell.setMaxHeight(USE_COMPUTED_SIZE); + + cell.applyCss(); + cell.layout(); + + arrangement.addCell(cell); + + double h = cell.prefHeight(forWidth) + getLineSpacing(r); + h = snapSizeY(h); // is this right? or snap(y + h) - snap(y) ? + y = snapPositionY(y - h); + count++; + + cell.setPosition(y, h/*, forWidth*/); + + flow.getChildren().remove(cell); + + // stop populating the top part of the sliding window + // when exceeded both pixel and line count margins + if ((-y > margin) && (count > topMarginCount)) { + break; + } + } + + arrangement.setTopHeight(-y); + + if (useContentWidth) { + width = unwrappedWidth + leftSide + rightSide + leftPadding + rightPadding; + } + + // lay out gutters + if (leftDecorator == null) { + leftGutter.setVisible(false); // TODO perhaps use bindings, and rely on .isVisible() here? + } else { + leftGutter.setVisible(true); + layoutInArea(leftGutter, 0.0, 0.0, leftSide, height, 0.0, HPos.CENTER, VPos.CENTER); + } + + if (rightDecorator == null) { + rightGutter.setVisible(false); + } else { + rightGutter.setVisible(true); + layoutInArea(rightGutter, width - rightSide, 0.0, rightSide, height, 0.0, HPos.CENTER, VPos.CENTER); + } + + layoutInArea(content, leftSide, 0.0, width - leftSide - rightSide, height, 0.0, HPos.CENTER, VPos.CENTER); + + if (wrap) { + double w = viewPortWidth(); + setUnwrappedContentWidth(w); + } else { + if (unwrappedContentWidth() != unwrappedWidth) { + setUnwrappedContentWidth(unwrappedWidth); + + if (useContentWidth) { + requestControlLayout(false); + } + } + } + + if (useContentWidth) { + updatePrefWidth(); + } + + boolean vsbVisible = useContentHeight ? + false : + (arrangement().getTotalHeight() + topPadding + bottomPadding) > viewPortHeight(); + + if (vsbVisible != vscroll.isVisible()) { + vscroll.setVisible(vsbVisible); + requestParentLayout(); + //System.out.println("vsb visible=" + vsbVisible); // FIX + } + + boolean hsbVisible = (wrap || useContentWidth) ? + false : + (unwrappedWidth + leftPadding + rightPadding) > viewPortWidth(); + + if (hscroll.isVisible() != hsbVisible) { + hscroll.setVisible(hsbVisible); + requestParentLayout(); + //System.out.println("hsb visible=" + hsbVisible); // FIX + } + + if (useContentHeight) { + double h = getFlowHeight(); + double prev = getPrefHeight(); + setPrefHeight(h); + //System.out.println("vflow.setPrefHeight=" + h + " prev=" + prev); // FIX + + // this "works" except for change model + requestParentLayout(); + + // avoids infinite layout loop in MultipleStackedBoxWindow but ... why? + if (h != prev) { + requestLayout(); + // weird, need this to make sure the reflow happens when changing models + Platform.runLater(() -> layoutChildren()); + } + } else { + if (getPrefHeight() != USE_COMPUTED_SIZE) { + setPrefHeight(USE_COMPUTED_SIZE); + } + } + + placeCells(); + } + + protected void placeCells() { + boolean wrap = control.isWrapText() && !control.isUseContentWidth(); + double w = wrap ? viewPortWidth() : Params.MAX_WIDTH_FOR_LAYOUT; + double x = snapPositionX(-getOffsetX()); + + leftGutter.getChildren().clear(); + rightGutter.getChildren().clear(); + + boolean addLeft = control.getLeftDecorator() != null; + boolean addRight = control.getRightDecorator() != null; + + int sz = arrangement.getVisibleCellCount(); + for (int i = 0; i < sz; i++) { + TextCell cell = arrangement.getCellAt(i); + double h = cell.getCellHeight(); + double y = cell.getY(); + flow.layoutInArea(cell, x, y, w, h); + + // this step is needed to get the correct caret path afterwards + cell.layout(); + + // place side nodes + if (addLeft) { + Node n = arrangement.getLeftNodeAt(i); + if (n != null) { + leftGutter.getChildren().add(n); + n.applyCss(); + leftGutter.layoutInArea(n, 0.0, y, leftGutter.getWidth(), h); + } + } + + if (addRight) { + Node n = arrangement.getRightNodeAt(i); + if (n != null) { + rightGutter.getChildren().add(n); + n.applyCss(); + rightGutter.layoutInArea(n, 0.0, y, rightGutter.getWidth(), h); + } + } + } + } + + private double getLineSpacing(Region r) { + if (r instanceof TextFlow f) { + return f.getLineSpacing(); + } + return 0.0; + } + + public double getViewHeight() { + return flow.getHeight(); + } + + public void pageUp() { + scrollVerticalPixels(-getViewHeight()); + } + + public void pageDown() { + scrollVerticalPixels(getViewHeight()); + } + + public void scrollVerticalFraction(double fractionOfHeight) { + scrollVerticalPixels(getViewHeight() * fractionOfHeight); + } + + /** scroll by a number of pixels, delta must not exceed the view height in absolute terms */ + public void scrollVerticalPixels(double delta) { + scrollVerticalPixels(delta, false); + } + + /** scroll by a number of pixels, delta must not exceed the view height in absolute terms */ + public void scrollVerticalPixels(double delta, boolean forceLayout) { + Origin or = arrangement().computeOrigin(delta); + if (or != null) { + setOrigin(or); + if (forceLayout) { + layoutChildren(); + } + } + } + + public void scrollHorizontalFraction(double delta) { + double w = flow.getWidth() + leftPadding + rightPadding; + scrollHorizontalPixels(delta * w); + } + + public void scrollHorizontalPixels(double delta) { + double off = getOffsetX() + delta; + double w = flow.getWidth(); + if (off < -leftPadding) { + off = -leftPadding; + } else if (off + w > (unwrappedContentWidth() + leftPadding)) { + off = Math.max(0.0, unwrappedContentWidth() + leftPadding - w); + } + setOffsetX(off); + // no need to recompute the flow + placeCells(); + updateCaretAndSelection(); + } + + /** scrolls to visible area, using vflow.content coordinates */ + public void scrollToVisible(double x, double y) { + if (y < 0.0) { + // above viewport + scrollVerticalPixels(y); + } else if (y >= getViewHeight()) { + // below viewport + scrollVerticalPixels(y - getViewHeight()); + } + + scrollHorizontalToVisible(x); + } + + public void scrollCaretToVisible() { + CaretInfo c = getCaretInfo(); + if (c == null) { + // caret is outside of the layout; let's set the origin first to the caret position + // and then block scroll to avoid scrolling past the document end, if needed + TextPos p = control.getCaretPosition(); + if (p != null) { + int ix = p.index(); + Origin or = new Origin(ix, 0.0); + boolean moveDown = (ix > getOrigin().index()); + setOrigin(or); + // TODO this can be null? + c = getCaretInfo(); + if (moveDown) { + scrollVerticalPixels(c.getMaxY() - c.getMinY() - getViewHeight()); + } + checkForExcessiveWhitespaceAtTheEnd(); + } + } else { + // block scroll, if needed + if (c.getMinY() < 0.0) { + scrollVerticalPixels(c.getMinY()); + } else if (c.getMaxY() > getViewHeight()) { + scrollVerticalPixels(c.getMaxY() - getViewHeight()); + } + + if (!control.isWrapText()) { + // FIX primary caret + double x = c.getMinX(); + if (x + leftPadding < 0.0) { + scrollHorizontalToVisible(x); + } else { + scrollHorizontalToVisible(c.getMaxX()); + } + } + } + } + + /** x - vflow.content coordinate */ + private void scrollHorizontalToVisible(double x) { + if (!control.isWrapText()) { + x += leftPadding; + double cw = flow.getWidth(); + double off; + if (x < 0.0) { + off = Math.max(getOffsetX() + x - Params.HORIZONTAL_GUARD, 0.0); + } else if (x > cw) { + off = getOffsetX() + x - cw + Params.HORIZONTAL_GUARD; + } else { + return; + } + + setOffsetX(off); + placeCells(); + updateCaretAndSelection(); + } + } + + protected void checkForExcessiveWhitespaceAtTheEnd() { + double delta = arrangement().bottomHeight() - getViewHeight(); + if (delta < 0) { + if (getOrigin().index() == 0) { + if (getOrigin().offset() <= -topPadding) { + return; + } + } + scrollVerticalPixels(delta); + } + } + + @Override + public void onContentChange(ContentChange ch) { + if (ch.isEdit()) { + Origin newOrigin = computeNewOrigin(ch); + if (newOrigin != null) { + setOrigin(newOrigin); + } + } + // TODO this could be more advanced to reduce the amount of re-computation and re-flow + // TODO clear cache >= start, update layout + cellCache.clear(); + // TODO rebuild from start.lineIndex() + requestLayout(); + } + + private Origin computeNewOrigin(ContentChange ch) { + int startIndex = ch.getStart().index(); + int endIndex = ch.getEnd().index(); + // TODO store position of the last visible symbol, use that to compare with 'start' to avoid reflow + Origin or = getOrigin(); + int lineDelta = endIndex - startIndex + ch.getLinesAdded(); + + // jump to start if the old origin is within the changed range + if ((startIndex <= or.index()) && (or.index() < (startIndex + lineDelta))) { + return new Origin(startIndex, 0); + } + + // adjust index only if the end precedes the origin + if (lineDelta != 0) { + if ( + (endIndex < or.index()) || + ( + (endIndex == or.index()) && + (ch.getEnd().offset() < or.offset()) + ) + ) + { + return new Origin(or.index() + lineDelta, or.offset()); + } + } + + return null; + } + + @Override + public StyleAttrs resolveStyles(StyleAttrs attrs) { + if (attrs == null) { + return attrs; + } + CssStyles css = attrs.get(CssStyles.CSS); + if (css == null) { + // no conversion is needed + return attrs; + } + + String directStyle = css.style(); + String[] names = css.names(); + + getChildren().add(measurer); + try { + measurer.setStyle(directStyle); + if (names == null) { + measurer.getStyleClass().clear(); + } else { + measurer.getStyleClass().setAll(names); + } + measurer.applyCss(); + return StyleAttrs.fromTextNode(measurer); + } finally { + getChildren().remove(measurer); + } + } + + @Override + public WritableImage snapshot(Node n) { + n.setManaged(false); + getChildren().add(n); + try { + n.applyCss(); + if (n instanceof Region r) { + double w = unwrappedContentWidth(); + double h = r.prefHeight(w); + layoutInArea(r, 0, -h, w, h, 0, HPos.CENTER, VPos.CENTER); + } + return n.snapshot(null, null); + } finally { + getChildren().remove(n); + } + } + + public void handleUseContentHeight() { + boolean on = control.isUseContentHeight(); + if (on) { + setUnwrappedContentWidth(0.0); + setOrigin(new Origin(0, -topPadding)); + setOffsetX(-leftPadding); + } + requestControlLayout(false); + } + + public void handleUseContentWidth() { + boolean on = control.isUseContentWidth(); + if (on) { + setUnwrappedContentWidth(0.0); + setOrigin(new Origin(0, -topPadding)); + setOffsetX(-leftPadding); + } + requestControlLayout(false); + } + + @Override + public void requestLayout() { + dirty = true; + super.requestLayout(); + } + + /** + * Requests full layout with optional clearing of the cached cells. + * @param clearCache if true, clears the cell cache + */ + public void requestControlLayout(boolean clearCache) { + if (clearCache) { + cellCache.clear(); + } + requestParentLayout(); + requestLayout(); + } + + // FIX same treatment as with usePrefHeight + private void updatePrefWidth() { + if (!control.prefWidthProperty().isBound()) { + double w = getFlowWidth(); + if (w >= 0.0) { + if (vscroll.isVisible()) { + w += vscroll.getWidth(); + } + } + + //D.p("w=", w); // FIX + + Parent parent = getParent(); + if (parent instanceof Region r) { + if (r.getPrefWidth() != w) { + r.setPrefWidth(w); + control.getParent().requestLayout(); + requestControlLayout(false); + } + } + } + } + + @Override + protected double computePrefHeight(double width) { + if (control.isUseContentHeight()) { + return getFlowHeight(); + } + return super.computePrefHeight(width); + } + + public double getFlowHeight() { + return snapSizeY(Math.max(Params.LAYOUT_MIN_HEIGHT, arrangement().bottomHeight())); + } + + public double getFlowWidth() { + return + arrangement().getUnwrappedWidth() + + snapSizeX(leftSide) + + snapSizeX(rightSide) + + leftPadding + + rightPadding + + snapSizeX(Params.HORIZONTAL_GUARD); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/VFlowCellContext.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/VFlowCellContext.java new file mode 100644 index 00000000000..5dcb1a557a6 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/VFlowCellContext.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich; + +import javafx.scene.Node; +import jfx.incubator.scene.control.rich.CellContext; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Assist in creating virtualized text cells. + */ +class VFlowCellContext implements CellContext { + private Node node; + private StyleAttrs attrs; + private final StringBuilder style = new StringBuilder(); + + public VFlowCellContext() { + } + + @Override + public void addStyle(String fxStyle) { + style.append(fxStyle); + } + + @Override + public StyleAttrs getAttributes() { + return attrs; + } + + @Override + public Node getNode() { + return node; + } + + void reset(Node n, StyleAttrs a) { + this.node = n; + this.attrs = a; + style.setLength(0); + } + + void apply() { + if (style.length() > 0) { + String s = style.toString(); + node.setStyle(s); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/AbstractFilter.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/AbstractFilter.java new file mode 100644 index 00000000000..71921cb3240 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/AbstractFilter.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 1997, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// adapted from package javax.swing.text.rtf; +package com.sun.jfx.incubator.scene.control.rich.rtf; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; + +/** + * A generic superclass for streams which read and parse text + * consisting of runs of characters interspersed with occasional + * ``specials'' (formatting characters). + * + *

Most of the functionality + * of this class would be redundant except that the + * ByteToChar converters + * are suddenly private API. Presumably this class will disappear + * when the API is made public again. (sigh) That will also let us handle + * multibyte character sets... + * + *

A subclass should override at least write(char) + * and writeSpecial(int). For efficiency's sake it's a + * good idea to override write(String) as well. The subclass' + * initializer may also install appropriate translation and specials tables. + * + * @see OutputStream + */ +abstract class AbstractFilter extends OutputStream { + /** A table mapping bytes to characters */ + protected char[] translationTable; + /** A table indicating which byte values should be interpreted as + * characters and which should be treated as formatting codes */ + protected boolean[] specialsTable; + + /** A translation table which does ISO Latin-1 (trivial) */ + static final char[] latin1TranslationTable; + /** A specials table which indicates that no characters are special */ + static final boolean[] noSpecialsTable; + /** A specials table which indicates that all characters are special */ + static final boolean[] allSpecialsTable; + + static { + noSpecialsTable = new boolean[256]; + for (int i = 0; i < 256; i++) { + noSpecialsTable[i] = false; + } + allSpecialsTable = new boolean[256]; + for (int i = 0; i < 256; i++) { + allSpecialsTable[i] = true; + } + latin1TranslationTable = new char[256]; + for (int i = 0; i < 256; i++) { + latin1TranslationTable[i] = (char)i; + } + } + + /** + * A convenience method that reads text from a FileInputStream + * and writes it to the receiver. + * The format in which the file + * is read is determined by the concrete subclass of + * AbstractFilter to which this method is sent. + *

This method does not close the receiver after reaching EOF on + * the input stream. + * The user must call close() to ensure that all + * data are processed. + * + * @param in An InputStream providing text. + */ + public void readFromStream(InputStream in) throws IOException { + in.transferTo(this); + } + + public void readFromReader(Reader in) throws IOException { + char[] buf = new char[2048]; + while (true) { + int count = in.read(buf); + if (count < 0) { + break; + } + for (int i = 0; i < count; i++) { + this.write(buf[i]); + } + } + } + + public AbstractFilter() { + translationTable = latin1TranslationTable; + specialsTable = noSpecialsTable; + } + + /** + * Implements the abstract method of OutputStream, of which this class + * is a subclass. + */ + @Override + public void write(int b) throws IOException { + b &= 0xff; + if (specialsTable[b]) + writeSpecial(b); + else { + char ch = translationTable[b]; + if (ch != (char)0) { + write(ch); + } + } + } + + /** + * Implements the buffer-at-a-time write method for greater + * efficiency. + * + *

PENDING: Does write(byte[]) + * call write(byte[], int, int) or is it the other way + * around? + */ + @Override + public void write(byte[] buf, int off, int len) throws IOException { + StringBuilder accumulator = null; + while (len > 0) { + int b = (buf[off] & 0xff); + if (specialsTable[b]) { + if (accumulator != null) { + write(accumulator.toString()); + accumulator = null; + } + writeSpecial(b); + } else { + char ch = translationTable[b]; + if (ch != (char)0) { + if (accumulator == null) { + accumulator = new StringBuilder(); + } + accumulator.append(ch); + } + } + + len--; + off++; + } + + if (accumulator != null) { + write(accumulator.toString()); + } + } + + /** + * Hopefully, all subclasses will override this method to accept strings + * of text, but if they don't, AbstractFilter's implementation + * will spoon-feed them via write(char). + * + * @param s The string of non-special characters written to the + * OutputStream. + */ + public void write(String s) throws IOException { + int length = s.length(); + for (int i = 0; i < length; i++) { + write(s.charAt(i)); + } + } + + /** + * Subclasses must provide an implementation of this method which + * accepts a single (non-special) character. + * + * @param ch The character written to the OutputStream. + */ + protected abstract void write(char ch) throws IOException; + + /** + * Subclasses must provide an implementation of this method which + * accepts a single special byte. No translation is performed + * on specials. + * + * @param b The byte written to the OutputStream. + */ + protected abstract void writeSpecial(int b) throws IOException; +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/AttrSet.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/AttrSet.java new file mode 100644 index 00000000000..a6f314f543a --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/AttrSet.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich.rtf; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Set; +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Attribute Container + */ +public class AttrSet { + private final HashMap attrs = new HashMap<>(); + private AttrSet parent; + + public AttrSet(AttrSet a) { + } + + public AttrSet() { + } + + public Object getAttribute(Object attr) { + Object v = attrs.get(attr); + if (v == null) { + if (parent != null) { + v = parent.getAttribute(attr); + } + } + return v; + } + + public Set getAttributeNames() { + return attrs.keySet(); + } + + public void addAttribute(Object attr, Object value) { + attrs.put(attr, value); + } + + public void addAttributes(AttrSet a) { + attrs.putAll(a.attrs); + } + + public void removeAttribute(Object attr) { + attrs.remove(attr); + } + + /** + * Sets the resolving parent. This is the set + * of attributes to resolve through if an attribute + * isn't defined locally. + * + * @param parent the parent + */ + public void setResolveParent(AttrSet parent) { + this.parent = parent; + } + + public StyleAttrs getStyleAttrs() { + return StyleAttrs.builder(). + setBold(getBoolean(StyleAttrs.BOLD)). + setFontFamily(getString(StyleAttrs.FONT_FAMILY)). + setItalic(getBoolean(StyleAttrs.ITALIC)). + setTextColor(getColor(StyleAttrs.TEXT_COLOR)). + setUnderline(getBoolean(StyleAttrs.UNDERLINE)). + build(); + } + + private boolean getBoolean(Object attr) { + return Boolean.TRUE.equals(attrs.get(attr)); + } + + private String getString(Object attr) { + Object v = attrs.get(attr); + if (v instanceof String s) { + return s; + } + return null; + } + + private Color getColor(Object attr) { + Object v = attrs.get(attr); + if (v instanceof Color c) { + return c; + } + return null; + } + + public void setItalic(boolean on) { + attrs.put(StyleAttrs.ITALIC, on); + } + + public void setBold(boolean on) { + attrs.put(StyleAttrs.BOLD, on); + } + + public void setUnderline(boolean on) { + attrs.put(StyleAttrs.UNDERLINE, on); + } + + public void setForeground(Color c) { + attrs.put(StyleAttrs.TEXT_COLOR, c); + } + + public void setLeftIndent(double d) { + // TODO + } + + public void setRightIndent(double d) { + // TODO + } + + public void setFirstLineIndent(double d) { + // TODO + } + + public void setFontFamily(String fontFamily) { + attrs.put(StyleAttrs.FONT_FAMILY, fontFamily); + } + + public void setBackground(Color bg) { + // TODO + } + + public void setAlignment(TextAlignment left) { + // TODO + } + + /** + * An internal AttrSet holder. Original name: MockAttributeSet. + */ + public static class Holder extends AttrSet { + public HashMap backing; + + public boolean isEmpty() { + return backing.isEmpty(); + } + + public int getAttributeCount() { + return backing.size(); + } + + public boolean isDefined(Object name) { + return (backing.get(name)) != null; + } + + public boolean isEqual(AttrSet attr) { + throw new InternalError(); + } + + public AttrSet copyAttributes() { + throw new InternalError(); + } + + @Override + public Object getAttribute(Object name) { + return backing.get(name); + } + + public void addAttribute(StyleAttribute name, Object value) { + backing.put(name, value); + } + + @Override + public void addAttributes(AttrSet attr) { + for (Object k : attr.getAttributeNames()) { + Object v = attr.getAttribute(k); + backing.put(k, v); + } + } + + @Override + public void removeAttribute(Object name) { + backing.remove(name); + } + + public void removeAttributes(AttrSet attr) { + throw new InternalError(); + } + + public void removeAttributes(Enumeration en) { + throw new InternalError(); + } + + @Override + public void setResolveParent(AttrSet pp) { + throw new InternalError(); + } + + @Override + public Set getAttributeNames() { + return backing.keySet(); + } + + public boolean containsAttribute(Object name, Object value) { + throw new InternalError(); + } + + public boolean containsAttributes(AttrSet attr) { + throw new InternalError(); + } + + public AttrSet getResolveParent() { + throw new InternalError(); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFAttribute.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFAttribute.java new file mode 100644 index 00000000000..27bac15088d --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFAttribute.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 1997, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// adapted from package javax.swing.text.rtf; +package com.sun.jfx.incubator.scene.control.rich.rtf; + +import jfx.incubator.scene.control.rich.model.StyleAttribute; + +/** + * This abstract class defines a 1-1 mapping between + * an RTF keyword and a StyleAttribute attribute. + */ +abstract class RTFAttribute { + public static final int D_CHARACTER = 0; + public static final int D_PARAGRAPH = 1; + public static final int D_SECTION = 2; + public static final int D_DOCUMENT = 3; + public static final int D_META = 4; + + public abstract boolean set(AttrSet target); + + public abstract boolean set(AttrSet target, int parameter); + + public abstract boolean setDefault(AttrSet target); + + protected final int domain; + protected final StyleAttribute attribute; + protected final String rtfName; + + protected RTFAttribute(int domain, StyleAttribute attribute, String rtfName) { + this.domain = domain; + this.attribute = attribute; + this.rtfName = rtfName; + } + + public int domain() { + return domain; + } + + public StyleAttribute getStyleAttribute() { + return attribute; + } + + public String rtfName() { + return rtfName; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFAttributes.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFAttributes.java new file mode 100644 index 00000000000..748b193290f --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFAttributes.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 1997, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// adapted from package javax.swing.text.rtf; +package com.sun.jfx.incubator.scene.control.rich.rtf; + +import java.util.HashMap; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +class RTFAttributes { + private static final RTFAttribute[] attributes = { + new BooleanAttribute(RTFAttribute.D_CHARACTER, StyleAttrs.ITALIC, "i"), + new BooleanAttribute(RTFAttribute.D_CHARACTER, StyleAttrs.BOLD, "b"), + new BooleanAttribute(RTFAttribute.D_CHARACTER, StyleAttrs.UNDERLINE, "ul"), + new BooleanAttribute(RTFAttribute.D_CHARACTER, StyleAttrs.STRIKE_THROUGH, "strike") + }; + + public static HashMap attributesByKeyword() { + HashMap d = new HashMap(attributes.length); + for (RTFAttribute attribute : attributes) { + d.put(attribute.rtfName(), attribute); + } + return d; + } + + /** + * Defines a boolean attribute. + */ + static class BooleanAttribute extends RTFAttribute { + private final boolean rtfDefault; + private final boolean defaultValue; + + public BooleanAttribute(int domain, StyleAttribute s, String rtfName, boolean ds, boolean dr) { + super(domain, s, rtfName); + defaultValue = ds; + rtfDefault = dr; + } + + public BooleanAttribute(int d, StyleAttribute s, String r) { + super(d, s, r); + + defaultValue = false; + rtfDefault = false; + } + + @Override + public boolean set(AttrSet target) { + /* TODO: There's some ambiguity about whether this should + *set* or *toggle* the attribute. */ + target.addAttribute(attribute, Boolean.TRUE); + + return true; /* true indicates we were successful */ + } + + @Override + public boolean set(AttrSet target, int parameter) { + /* See above note in the case that parameter==1 */ + Boolean value = Boolean.valueOf(parameter != 0); + target.addAttribute(attribute, value); + return true; /* true indicates we were successful */ + } + + @Override + public boolean setDefault(AttrSet target) { + if (defaultValue != rtfDefault || (target.getAttribute(attribute) != null)) { + target.addAttribute(getStyleAttribute(), Boolean.valueOf(rtfDefault)); + } + return true; + } + } + + /** + * Defines an object attribute. + */ + static class AssertiveAttribute extends RTFAttribute { + private final Object value; + + public AssertiveAttribute(int d, StyleAttribute s, String r) { + super(d, s, r); + value = Boolean.valueOf(true); + } + + public AssertiveAttribute(int d, StyleAttribute s, String r, Object v) { + super(d, s, r); + value = v; + } + + public AssertiveAttribute(int d, StyleAttribute s, String r, int v) { + super(d, s, r); + value = Integer.valueOf(v); + } + + @Override + public boolean set(AttrSet target) { + if (value == null) { + target.removeAttribute(attribute); + } else { + target.addAttribute(attribute, value); + } + return true; + } + + @Override + public boolean set(AttrSet target, int parameter) { + return false; + } + + @Override + public boolean setDefault(AttrSet target) { + target.removeAttribute(attribute); + return true; + } + } + + /** + * Defines a numeric attribute. + */ + static class NumericAttribute extends RTFAttribute { + private final int rtfDefault; + private final Number defaultValue; + private final float scale; + + protected NumericAttribute(int d, StyleAttribute s, String r) { + super(d, s, r); + rtfDefault = 0; + defaultValue = null; + scale = 1f; + } + + public NumericAttribute(int d, StyleAttribute s, String r, int ds, int dr) { + this(d, s, r, Integer.valueOf(ds), dr, 1f); + } + + public NumericAttribute(int d, StyleAttribute s, String r, Number ds, int dr, float sc) { + super(d, s, r); + defaultValue = ds; + rtfDefault = dr; + scale = sc; + } + + @Override + public boolean set(AttrSet target) { + return false; + } + + @Override + public boolean set(AttrSet target, int parameter) { + Number v; + if (scale == 1f) { + v = Integer.valueOf(parameter); + } else { + v = Float.valueOf(parameter / scale); + } + target.addAttribute(attribute, v); + return true; + } + + @Override + public boolean setDefault(AttrSet target) { + Number old = (Number)target.getAttribute(attribute); + if (old == null) { + old = defaultValue; + } + if (old != null && ((scale == 1f && old.intValue() == rtfDefault) + || (Math.round(old.floatValue() * scale) == rtfDefault))) { + return true; + } + set(target, rtfDefault); + return true; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFParser.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFParser.java new file mode 100644 index 00000000000..d46aa82ec6f --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFParser.java @@ -0,0 +1,346 @@ +/* + * Copyright (c) 1997, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// adapted from package javax.swing.text.rtf; +package com.sun.jfx.incubator.scene.control.rich.rtf; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * RTFParser is a subclass of AbstractFilter which understands basic RTF syntax + * and passes a stream of control words, text, and begin/end group + * indications to its subclass. + * + * Normally programmers will only use RTFReader, a subclass of this class that knows what to + * do with the tokens this class parses. + * + * @see AbstractFilter + * @see RTFReader + */ +// copied from package javax.swing.text.rtf; +abstract class RTFParser extends AbstractFilter { + /** The current RTF group nesting level. */ + public int level; + private int state; + private StringBuffer currentCharacters; + private String pendingKeyword; // where keywords go while we read their parameters + private int pendingCharacter; // for the \'xx construct + private long binaryBytesLeft; // in a \bin blob? + ByteArrayOutputStream binaryBuf; + private boolean[] savedSpecials; + + // value for the 'state' variable + private final int S_text = 0; // reading random text + private final int S_backslashed = 1; // read a backslash, waiting for next + private final int S_token = 2; // reading a multicharacter token + private final int S_parameter = 3; // reading a token's parameter + private final int S_aftertick = 4; // after reading \' + private final int S_aftertickc = 5; // after reading \'x + private final int S_inblob = 6; // in a \bin blob + + /** Implemented by subclasses to interpret a parameter-less RTF keyword. + * The keyword is passed without the leading '/' or any delimiting + * whitespace. */ + public abstract boolean handleKeyword(String keyword); + + /** Implemented by subclasses to interpret a keyword with a parameter. + * @param keyword The keyword, as with handleKeyword(String). + * @param parameter The parameter following the keyword. */ + public abstract boolean handleKeyword(String keyword, int parameter); + + /** Implemented by subclasses to interpret text from the RTF stream. */ + public abstract void handleText(String text); + + public void handleText(char ch) { + handleText(String.valueOf(ch)); + } + + /** Implemented by subclasses to handle the contents of the \bin keyword. */ + public abstract void handleBinaryBlob(byte[] data); + + /** Implemented by subclasses to react to an increase + * in the nesting level. */ + public abstract void begingroup(); + + /** Implemented by subclasses to react to the end of a group. */ + public abstract void endgroup(); + + // table of non-text characters in rtf + static final boolean[] rtfSpecialsTable; + static { + rtfSpecialsTable = noSpecialsTable.clone(); + rtfSpecialsTable['\n'] = true; + rtfSpecialsTable['\r'] = true; + rtfSpecialsTable['{'] = true; + rtfSpecialsTable['}'] = true; + rtfSpecialsTable['\\'] = true; + } + + public RTFParser() { + currentCharacters = new StringBuffer(); + state = S_text; + pendingKeyword = null; + level = 0; + + specialsTable = rtfSpecialsTable; + } + + @Override + public void writeSpecial(int b) throws IOException { + write((char)b); + } + + @Override + public void write(String s) throws IOException { + if (state != S_text) { + int index = 0; + int length = s.length(); + while (index < length && state != S_text) { + write(s.charAt(index)); + index++; + } + + if (index >= length) { + return; + } + + s = s.substring(index); + } + + if (currentCharacters.length() > 0) { + currentCharacters.append(s); + } else { + handleText(s); + } + } + + @Override + @SuppressWarnings("fallthrough") + public void write(char ch) throws IOException { + boolean ok; + + switch (state) { + case S_text: + if (ch == '\n' || ch == '\r') { + break; // unadorned newlines are ignored + } else if (ch == '{') { + if (currentCharacters.length() > 0) { + handleText(currentCharacters.toString()); + currentCharacters = new StringBuffer(); + } + level++; + begingroup(); + } else if (ch == '}') { + if (currentCharacters.length() > 0) { + handleText(currentCharacters.toString()); + currentCharacters = new StringBuffer(); + } + if (level == 0) { + throw new IOException("Too many close-groups in RTF text"); + } + endgroup(); + level--; + } else if (ch == '\\') { + if (currentCharacters.length() > 0) { + handleText(currentCharacters.toString()); + currentCharacters = new StringBuffer(); + } + state = S_backslashed; + } else { + currentCharacters.append(ch); + } + break; + case S_backslashed: + if (ch == '\'') { + state = S_aftertick; + break; + } + if (!Character.isLetter(ch)) { + String kw = String.valueOf(ch); + if (!handleKeyword(kw)) { + //warning("Unknown keyword: " + kw + " (" + (byte)ch + ")"); + } + state = S_text; + pendingKeyword = null; + /* currentCharacters is already an empty stringBuffer */ + break; + } + + state = S_token; + /* FALL THROUGH */ + case S_token: + if (Character.isLetter(ch)) { + currentCharacters.append(ch); + } else { + pendingKeyword = currentCharacters.toString(); + currentCharacters = new StringBuffer(); + + // Parameter following? + if (Character.isDigit(ch) || (ch == '-')) { + state = S_parameter; + currentCharacters.append(ch); + } else { + ok = handleKeyword(pendingKeyword); +// if (!ok) { +// warning("Unknown keyword: " + pendingKeyword); +// } + pendingKeyword = null; + state = S_text; + + // Non-space delimiters get included in the text + if (!Character.isWhitespace(ch)) { + write(ch); + } + } + } + break; + case S_parameter: + if (Character.isDigit(ch)) { + currentCharacters.append(ch); + } else { + /* TODO: Test correct behavior of \bin keyword */ + if (pendingKeyword.equals("bin")) { /* magic layer-breaking kwd */ + long parameter = 0L; + try { + parameter = Long.parseLong(currentCharacters.toString()); + } catch (NumberFormatException e) { + //warning("Illegal number format " + currentCharacters.toString() + " in \bin tag"); + pendingKeyword = null; + currentCharacters = new StringBuffer(); + state = S_text; + // Delimiters here are interpreted as text too + if (!Character.isWhitespace(ch)) { + write(ch); + } + break; + } + pendingKeyword = null; + state = S_inblob; + int maxBytes = 4 * 1024 * 1024; + binaryBytesLeft = parameter; + + if (binaryBytesLeft > maxBytes) { + binaryBuf = new ByteArrayOutputStream(maxBytes); + } else if (binaryBytesLeft < 0) { + binaryBytesLeft = 0; + binaryBuf = new ByteArrayOutputStream((int)binaryBytesLeft); + } else { + binaryBuf = new ByteArrayOutputStream((int)binaryBytesLeft); + } + savedSpecials = specialsTable; + specialsTable = allSpecialsTable; + break; + } + + int parameter = 0; + try { + parameter = Integer.parseInt(currentCharacters.toString()); + ok = handleKeyword(pendingKeyword, parameter); +// if (!ok) { +// warning("Unknown keyword: " + pendingKeyword + " (param " + currentCharacters + ")"); +// } + } catch (NumberFormatException e) { + //warning("Illegal number format " + currentCharacters.toString() + " in " + pendingKeyword + " tag"); + } + pendingKeyword = null; + currentCharacters = new StringBuffer(); + state = S_text; + + // Delimiters here are interpreted as text too + if (!Character.isWhitespace(ch)) { + write(ch); + } + } + break; + case S_aftertick: + if (Character.digit(ch, 16) == -1) { + state = S_text; + } else { + pendingCharacter = Character.digit(ch, 16); + state = S_aftertickc; + } + break; + case S_aftertickc: + state = S_text; + if (Character.digit(ch, 16) != -1) { + pendingCharacter = pendingCharacter * 16 + Character.digit(ch, 16); + ch = translationTable[pendingCharacter]; + if (ch != 0) { + handleText(ch); + } + } + break; + case S_inblob: + if (binaryBytesLeft > 0) { + binaryBuf.write(ch); + binaryBytesLeft--; + } + if (binaryBytesLeft == 0) { + state = S_text; + specialsTable = savedSpecials; + savedSpecials = null; + handleBinaryBlob(binaryBuf.toByteArray()); + binaryBuf = null; + } + } + } + + /** Flushes any buffered but not yet written characters. + * Subclasses which override this method should call this + * method before flushing + * any of their own buffers. */ + @Override + public void flush() throws IOException { + super.flush(); + + if (state == S_text && currentCharacters.length() > 0) { + handleText(currentCharacters.toString()); + currentCharacters = new StringBuffer(); + } + } + + /** Closes the parser. Currently, this simply does a flush(), + * followed by some minimal consistency checks. */ + @Override + public void close() throws IOException { + flush(); + + if (state != S_text || level > 0) { + //warning("Truncated RTF file."); + + /* TODO: any sane way to handle termination in a non-S_text state? */ + /* probably not */ + + /* this will cause subclasses to behave more reasonably + some of the time */ + while (level > 0) { + endgroup(); + level--; + } + } + + super.close(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFReader.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFReader.java new file mode 100644 index 00000000000..b0a66a6fa7d --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/RTFReader.java @@ -0,0 +1,1449 @@ +/* + * Copyright (c) 1997, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// adapted from package javax.swing.text.rtf; +package com.sun.jfx.incubator.scene.control.rich.rtf; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StreamTokenizer; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import com.sun.jfx.incubator.scene.control.rich.SegmentStyledInput; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +/** + * Takes a sequence of RTF tokens and text and appends the text + * described by the RTF to a StyledDocument (the target). + * The RTF is lexed + * from the character stream by the RTFParser which is this class's + * superclass. + * + * This class is an indirect subclass of OutputStream. It must be closed + * in order to guarantee that all of the text has been sent to + * the text acceptor. + */ +public class RTFReader extends RTFParser { + /** Indicates the domain of a Style */ + private static final Object STYLE_TYPE = new Object(); + /** Value for StyleType indicating a section style */ + private static final Object STYLE_SECTION = new Object(); + /** Value for StyleType indicating a paragraph style */ + private static final Object STYLE_PARAGRAPH = new Object(); + /** Value for StyleType indicating a character style */ + private static final Object STYLE_CHARACTER = new Object(); + /** The style of the text following this style */ + private static final Object STYLE_NEXT = new Object(); + /** Whether the style is additive */ + private static final Object STYLE_ADDITIVE = new Object(); + /** Whether the style is hidden from the user */ + private static final Object STYLE_HIDDEN = new Object(); + + private final String text; + private ArrayList segments; + + /** Miscellaneous information about the parser's state. This + * dictionary is saved and restored when an RTF group begins + * or ends. */ + private HashMap parserState; /* Current parser state */ + /** This is the "dst" item from parserState. rtfDestination + * is the current rtf destination. It is cached in an instance + * variable for speed. */ + private Destination rtfDestination; + /** This holds the current document attributes. */ + private AttrSet documentAttributes; + + /** This Dictionary maps Integer font numbers to String font names. */ + private HashMap fontTable; + /** This array maps color indices to Color objects. */ + private Color[] colorTable; + /** This Map maps character style numbers to Style objects. */ + private HashMap characterStyles; + /** This Map maps paragraph style numbers to Style objects. */ + private HashMap paragraphStyles; + /** This Map maps section style numbers to Style objects. */ + private HashMap sectionStyles; + + /** true to indicate that if the next keyword is unknown, + * the containing group should be ignored. */ + private boolean ignoreGroupIfUnknownKeyword; + + /** The parameter of the most recently parsed \\ucN keyword, + * used for skipping alternative representations after a + * Unicode character. */ + private int skippingCharacters; + + private final AttrSet.Holder holder = new AttrSet.Holder(); + + private static final String DEFAULT_STYLE = "default"; + private final HashMap styles = initStyles(); // TODO can init default style on demand + + private static final HashMap straightforwardAttributes = RTFAttributes.attributesByKeyword(); + + /** textKeywords maps RTF keywords to single-character strings, + * for those keywords which simply insert some text. */ + private static final HashMap textKeywords = initTextKeywords(); + private static final HashMap characterSets = initCharacterSets(); + + /* TODO: per-font font encodings ( \fcharset control word ) ? */ + + /** + * Creates a new RTFReader instance. + * @param text the RTF input string + */ + public RTFReader(String text) { + this.text = text; + System.err.println(text); // FIX + + parserState = new HashMap<>(); + fontTable = new HashMap(); + documentAttributes = new AttrSet(); + } + + /** + * Processes the RTF input and generates a StyledInput instance. + * @return the StyledInput + * @throws IOException when an I/O error occurs. + */ + public StyledInput generateStyledInput() throws IOException { + if (segments == null) { + segments = new ArrayList<>(); + readFromReader(new StringReader(text)); + } + return SegmentStyledInput.of(segments); + } + + private static HashMap initTextKeywords() { + HashMap m = new HashMap<>(); + m.put("\\", "\\"); + m.put("{", "{"); + m.put("}", "}"); + m.put(" ", "\u00A0"); /* not in the spec... */ + m.put("~", "\u00A0"); /* nonbreaking space */ + m.put("_", "\u2011"); /* nonbreaking hyphen */ + m.put("bullet", "\u2022"); + m.put("emdash", "\u2014"); + m.put("emspace", "\u2003"); + m.put("endash", "\u2013"); + m.put("enspace", "\u2002"); + m.put("ldblquote", "\u201C"); + m.put("lquote", "\u2018"); + m.put("ltrmark", "\u200E"); + m.put("rdblquote", "\u201D"); + m.put("rquote", "\u2019"); + m.put("rtlmark", "\u200F"); + m.put("tab", "\u0009"); + m.put("zwj", "\u200D"); + m.put("zwnj", "\u200C"); + // There is no Unicode equivalent to an optional hyphen, as far as I can tell. + // TODO optional hyphen + m.put("-", "\u2027"); + return m; + } + + private static HashMap initCharacterSets() { + HashMap m = new HashMap<>(); + m.put("ansicpg", latin1TranslationTable); + return m; + } + + private HashMap initStyles() { + HashMap m = new HashMap<>(); + m.put(DEFAULT_STYLE, new Style()); + return m; + } + + private Style addStyle(String nm, Style parent) { + Style s = new Style(); + s.setResolveParent(parent); + styles.put(nm, s); + return s; + } + + private Style getDefaultStyle() { + return styles.get(DEFAULT_STYLE); + } + + /** + * Called when the RTFParser encounters a bin keyword in the RTF stream. + */ + @Override + public void handleBinaryBlob(byte[] data) { + if (skippingCharacters > 0) { + // a blob only counts as one character for skipping purposes + skippingCharacters--; + return; + } + + // TODO + } + + /** + * Handles any pure text (containing no control characters) in the input + * stream. Called by the superclass. */ + @Override + public void handleText(String text) { + if (skippingCharacters > 0) { + if (skippingCharacters >= text.length()) { + skippingCharacters -= text.length(); + return; + } else { + text = text.substring(skippingCharacters); + skippingCharacters = 0; + } + } + + if (rtfDestination != null) { + rtfDestination.handleText(text); + return; + } + } + + /** Called by the superclass when a new RTF group is begun. + * This implementation saves the current parserState, and gives + * the current destination a chance to save its own state. + * @see RTFParser#begingroup + */ + @Override + public void begingroup() { + if (skippingCharacters > 0) { + /* TODO this indicates an error in the RTF. Log it? */ + skippingCharacters = 0; + } + + /* we do this little dance to avoid cloning the entire state stack and + immediately throwing it away. */ + Object oldSaveState = parserState.get("_savedState"); + if (oldSaveState != null) { + parserState.remove("_savedState"); + } + @SuppressWarnings("unchecked") + HashMap saveState = (HashMap)parserState.clone(); + if (oldSaveState != null) { + saveState.put("_savedState", oldSaveState); + } + parserState.put("_savedState", saveState); + + if (rtfDestination != null) { + rtfDestination.begingroup(); + } + } + + /** Called by the superclass when the current RTF group is closed. + * This restores the parserState saved by begingroup() + * as well as invoking the endgroup method of the current + * destination. + * @see RTFParser#endgroup + */ + @Override + public void endgroup() { + if (skippingCharacters > 0) { + /* NB this indicates an error in the RTF. Log it? */ + skippingCharacters = 0; + } + + @SuppressWarnings("unchecked") + HashMap restoredState = (HashMap)parserState.get("_savedState"); + Destination restoredDestination = (Destination)restoredState.get("dst"); + if (restoredDestination != rtfDestination) { + rtfDestination.close(); /* allow the destination to clean up */ + rtfDestination = restoredDestination; + } + HashMap oldParserState = parserState; + parserState = restoredState; + if (rtfDestination != null) { + rtfDestination.endgroup(oldParserState); + } + } + + protected void setRTFDestination(Destination newDestination) { + /* Check that setting the destination won't close the + current destination (should never happen) */ + HashMap previousState = (HashMap)parserState.get("_savedState"); + if (previousState != null) { + if (rtfDestination != previousState.get("dst")) { + //warning("Warning, RTF destination overridden, invalid RTF."); + rtfDestination.close(); + } + } + rtfDestination = newDestination; + parserState.put("dst", rtfDestination); + } + + /** Called by the user when there is no more input (i.e., + * at the end of the RTF file.) + * + * @see OutputStream#close + */ + @Override + public void close() throws IOException { + // FIX remove this +// Enumeration docProps = documentAttributes.getAttributeNames(); +// while (docProps.hasMoreElements()) { +// Object propName = docProps.nextElement(); +// //target.putProperty(propName, documentAttributes.getAttribute(propName)); +// } + + super.close(); + } + + /** + * Handles a parameterless RTF keyword. This is called by the superclass + * (RTFParser) when a keyword is found in the input stream. + * + * @return true if the keyword is recognized and handled; + * false otherwise + * @see RTFParser#handleKeyword + */ + @Override + public boolean handleKeyword(String keyword) { + String item; + boolean ignoreGroupIfUnknownKeywordSave = ignoreGroupIfUnknownKeyword; + + if (skippingCharacters > 0) { + skippingCharacters--; + return true; + } + + ignoreGroupIfUnknownKeyword = false; + + if ((item = textKeywords.get(keyword)) != null) { + handleText(item); + return true; + } + + if (keyword.equals("fonttbl")) { + setRTFDestination(new FonttblDestination()); + return true; + } + + if (keyword.equals("colortbl")) { + setRTFDestination(new ColortblDestination()); + return true; + } + + if (keyword.equals("stylesheet")) { + setRTFDestination(new StylesheetDestination()); + return true; + } + + if (keyword.equals("info")) { + setRTFDestination(new Destination()); + return false; + } + + if (keyword.equals("mac")) { + setCharacterSet("mac"); + return true; + } + + if (keyword.equals("ansi")) { + setCharacterSet("ansi"); + return true; + } + + if (keyword.equals("next")) { + setCharacterSet("NeXT"); + return true; + } + + if (keyword.equals("pc")) { + setCharacterSet("cpg437"); /* IBM Code Page 437 */ + return true; + } + + if (keyword.equals("pca")) { + setCharacterSet("cpg850"); /* IBM Code Page 850 */ + return true; + } + + if (keyword.equals("*")) { + ignoreGroupIfUnknownKeyword = true; + return true; + } + + if (rtfDestination != null) { + if (rtfDestination.handleKeyword(keyword)) { + return true; + } + } + + // this point is reached only if the keyword is unrecognized + // other destinations we don't understand and therefore ignore + switch(keyword) { + case "aftncn": + case "aftnsep": + case "aftnsepc": + case "annotation": + case "atnauthor": + case "atnicn": + case "atnid": + case "atnref": + case "atntime": + case "atrfend": + case "atrfstart": + case "bkmkend": + case "bkmkstart": + case "datafield": + case "do": + case "dptxbxtext": + case "falt": + case "field": + case "file": + case "filetbl": + case "fname": + case "fontemb": + case "fontfile": + case "footer": + case "footerf": + case "footerl": + case "footerr": + case "footnote": + case "ftncn": + case "ftnsep": + case "ftnsepc": + case "header": + case "headerf": + case "headerl": + case "headerr": + case "keycode": + case "nextfile": + case "object": + case "pict": + case "pn": + case "pnseclvl": + case "pntxtb": + case "pntxta": + case "revtbl": + case "rxe": + case "tc": + case "template": + case "txe": + case "xe": + ignoreGroupIfUnknownKeywordSave = true; + break; + } + + if (ignoreGroupIfUnknownKeywordSave) { + setRTFDestination(new Destination()); + } + + return false; + } + + /** + * Handles an RTF keyword and its integer parameter. + * This is called by the superclass + * (RTFParser) when a keyword is found in the input stream. + * + * @return true if the keyword is recognized and handled; + * false otherwise + * @see RTFParser#handleKeyword + */ + @Override + public boolean handleKeyword(String keyword, int parameter) { + boolean ignoreGroupIfUnknownKeywordSave = ignoreGroupIfUnknownKeyword; + + if (skippingCharacters > 0) { + skippingCharacters--; + return true; + } + + ignoreGroupIfUnknownKeyword = false; + + if (keyword.equals("uc")) { + /* count of characters to skip after a unicode character */ + parserState.put("UnicodeSkip", Integer.valueOf(parameter)); + return true; + } + if (keyword.equals("u")) { + if (parameter < 0) { + parameter = parameter + 65536; + } + handleText((char)parameter); + Number skip = (Number)(parserState.get("UnicodeSkip")); + if (skip != null) { + skippingCharacters = skip.intValue(); + } else { + skippingCharacters = 1; + } + return true; + } + + if (keyword.equals("rtf")) { + //rtfversion = parameter; + setRTFDestination(new DocumentDestination()); + return true; + } + + if (keyword.startsWith("NeXT") || keyword.equals("private")) { + ignoreGroupIfUnknownKeywordSave = true; + } + + if (keyword.contains("ansicpg")) { + setCharacterSet("ansicpg"); + return true; + } + + if (rtfDestination != null) { + if (rtfDestination.handleKeyword(keyword, parameter)) { + return true; + } + } + + // this point is reached only if the keyword is unrecognized + if (ignoreGroupIfUnknownKeywordSave) { + setRTFDestination(new Destination()); + } + + return false; + } + + /** + * setCharacterSet sets the current translation table to correspond with + * the named character set. The character set is loaded if necessary. + * + * @see AbstractFilter + */ + public void setCharacterSet(String name) { + Object set; + + try { + set = getCharacterSet(name); + } catch (Exception e) { + //warning("Exception loading RTF character set \"" + name + "\": " + e); + set = null; + } + + if (set != null) { + translationTable = (char[])set; + } else { + //warning("Unknown RTF character set \"" + name + "\""); + if (!name.equals("ansi")) { + try { + translationTable = (char[])getCharacterSet("ansi"); + } catch (IOException e) { + throw new InternalError("RTFReader: Unable to find character set resources (" + e + ")", e); + } + } + } + } + + /** Adds a character set to the RTFReader's list + * of known character sets */ + private static void defineCharacterSet(String name, char[] table) { + if (table.length < 256) { + throw new IllegalArgumentException("Translation table must have 256 entries."); + } + characterSets.put(name, table); + } + + /** Looks up a named character set. A character set is a 256-entry + * array of characters, mapping unsigned byte values to their Unicode + * equivalents. The character set is loaded if necessary. + * + * @return the character set + */ + public static Object getCharacterSet(final String name) throws IOException { + char[] set = characterSets.get(name); + if (set == null) { + try (InputStream in = RTFReader.class.getResourceAsStream("charsets/" + name + ".txt")) { + set = readCharset(in); + defineCharacterSet(name, set); + } + } + return set; + } + + /** Parses a character set from an InputStream. The character set + * must contain 256 decimal integers, separated by whitespace, with + * no punctuation. B- and C- style comments are allowed. + * + * @return the newly read character set + */ + static char[] readCharset(InputStream strm) throws IOException { + char[] values = new char[256]; + + try (BufferedReader rd = new BufferedReader(new InputStreamReader(strm, StandardCharsets.ISO_8859_1))) { + StreamTokenizer in = new StreamTokenizer(rd); + in.eolIsSignificant(false); + in.commentChar('#'); + in.slashSlashComments(true); + in.slashStarComments(true); + + int i = 0; + while (i < 256) { + int ttype; + try { + ttype = in.nextToken(); + } catch (Exception e) { + throw new IOException("Unable to read from character set file (" + e + ")"); + } + if (ttype != StreamTokenizer.TT_NUMBER) { + // System.out.println("Bad token: type=" + ttype + " tok=" + in.sval); + throw new IOException("Unexpected token in character set file"); + // continue; + } + values[i] = (char)(in.nval); + i++; + } + } + + return values; + } + + /** + * The base class for an RTF destination. + * The RTF reader always has a current destination + * which is where text is sent. This class provides a discarding destination: + * it accepts all keywords and text but does nothing with them. + */ + static class Destination { + public void handleBinaryBlob(byte[] data) { + } + + public void handleText(String text) { + } + + public boolean handleKeyword(String text) { + /* Accept and discard keywords. */ + return true; + } + + public boolean handleKeyword(String text, int parameter) { + /* Accept and discard parameterized keywords. */ + return true; + } + + public void begingroup() { + } + + public void endgroup(Map oldState) { + } + + public void close() { + } + } + + /** + * Reads the fonttbl group, inserting fonts into the RTFReader's fontTable map. + */ + class FonttblDestination extends Destination { + private int nextFontNumber; + private Integer fontNumberKey; + private String nextFontFamily; + + @Override + public void handleText(String text) { + int semicolon = text.indexOf(';'); + String fontName; + + if (semicolon > -1) { + fontName = text.substring(0, semicolon); + } else { + fontName = text; + } + + if (nextFontNumber == -1 && fontNumberKey != null) { + //font name might be broken across multiple calls + fontName = fontTable.get(fontNumberKey) + fontName; + } else { + fontNumberKey = Integer.valueOf(nextFontNumber); + } + fontTable.put(fontNumberKey, fontName); + + nextFontNumber = -1; + nextFontFamily = null; + } + + @Override + public boolean handleKeyword(String keyword) { + if (keyword.charAt(0) == 'f') { + nextFontFamily = keyword.substring(1); + return true; + } + return false; + } + + @Override + public boolean handleKeyword(String keyword, int parameter) { + if (keyword.equals("f")) { + nextFontNumber = parameter; + return true; + } + return false; + } + } + + /** + * Reads the colortbl group. Upon end-of-group, the RTFReader's + * color table is set to an array containing the read colors. + */ + class ColortblDestination extends Destination { + private int red; + private int green; + private int blue; + private final ArrayList colors = new ArrayList<>(); + + public ColortblDestination() { + } + + @Override + public void handleText(String text) { + for (int index = 0; index < text.length(); index++) { + if (text.charAt(index) == ';') { + Color newColor; + newColor = Color.rgb(red, green, blue); + colors.add(newColor); + } + } + } + + @Override + public void close() { + int sz = colors.size(); + colorTable = colors.toArray(new Color[sz]); + } + + @Override + public boolean handleKeyword(String keyword, int parameter) { + switch (keyword) { + case "red": + red = parameter; + return true; + case "green": + green = parameter; + return true; + case "blue": + blue = parameter; + return true; + } + return false; + } + + @Override + public boolean handleKeyword(String keyword) { + // Colortbls don't understand any parameterless keywords + return false; + } + } + + /** + * Handles the stylesheet keyword. Styles are read and sorted + * into the three style arrays in the RTFReader. + */ + class StylesheetDestination extends Destination { + private HashMap definedStyles = new HashMap<>(); + + public StylesheetDestination() { + } + + @Override + public void begingroup() { + setRTFDestination(new StyleDefiningDestination()); + } + + @Override + public void close() { + HashMap chrStyles = new HashMap<>(); + HashMap pgfStyles = new HashMap<>(); + HashMap secStyles = new HashMap<>(); + for (StyleDefiningDestination style : definedStyles.values()) { + Style defined = style.realize(); + String stype = (String)defined.getAttribute(STYLE_TYPE); + Map toMap; + if (stype.equals(STYLE_SECTION)) { + toMap = secStyles; + } else if (stype.equals(STYLE_CHARACTER)) { + toMap = chrStyles; + } else { + toMap = pgfStyles; + } + toMap.put(style.number, defined); + } + if (!(chrStyles.isEmpty())) { + characterStyles = chrStyles; + } + if (!(pgfStyles.isEmpty())) { + paragraphStyles = pgfStyles; + } + if (!(secStyles.isEmpty())) { + sectionStyles = secStyles; + } + } + + /** This subclass handles an individual style */ + class StyleDefiningDestination extends AttributeTrackingDestination { + private static final int STYLENUMBER_NONE = 222; + private boolean additive; + private boolean characterStyle; + private boolean sectionStyle; + public String styleName; + public int number; + private int basedOn = STYLENUMBER_NONE; + private int nextStyle = STYLENUMBER_NONE; + private boolean hidden; + private Style realizedStyle; + + @Override + public void handleText(String text) { + if (styleName != null) { + styleName = styleName + text; + } else { + styleName = text; + } + } + + @Override + public void close() { + int semicolon = (styleName == null) ? 0 : styleName.indexOf(';'); + if (semicolon > 0) { + styleName = styleName.substring(0, semicolon); + } + definedStyles.put(Integer.valueOf(number), this); + super.close(); + } + + @Override + public boolean handleKeyword(String keyword) { + switch (keyword) { + case "additive": + additive = true; + return true; + case "shidden": + hidden = true; + return true; + } + return super.handleKeyword(keyword); + } + + @Override + public boolean handleKeyword(String keyword, int parameter) { + // As per http://www.biblioscape.com/rtf15_spec.htm#Heading2 + // we are restricting control word delimiter numeric value + // to be within -32767 through 32767 + if (parameter > 32767) { + parameter = 32767; + } else if (parameter < -32767) { + parameter = -32767; + } + + switch (keyword) { + case "s": + characterStyle = false; + sectionStyle = false; + number = parameter; + return true; + case "cs": + characterStyle = true; + sectionStyle = false; + number = parameter; + return true; + case "ds": + characterStyle = false; + sectionStyle = true; + number = parameter; + return true; + case "sbasedon": + basedOn = parameter; + return true; + case "snext": + nextStyle = parameter; + return true; + } + + return super.handleKeyword(keyword, parameter); + } + + public Style realize() { + return realize(null); + } + + private Style realize(Set alreadyMetBasisIndexSet) { + Style basis = null; + Style next = null; + + if (alreadyMetBasisIndexSet == null) { + alreadyMetBasisIndexSet = new HashSet<>(); + } + + if (realizedStyle != null) { + return realizedStyle; + } + + if (basedOn != STYLENUMBER_NONE && alreadyMetBasisIndexSet.add(basedOn)) { + StyleDefiningDestination styleDest; + styleDest = definedStyles.get(basedOn); + if (styleDest != null && styleDest != this) { + basis = styleDest.realize(alreadyMetBasisIndexSet); + } + } + + /* NB: Swing StyleContext doesn't allow distinct styles with + the same name; RTF apparently does. This may confuse the + user. */ + realizedStyle = addStyle(styleName, basis); + + if (characterStyle) { + realizedStyle.addAttributes(currentTextAttributes()); + realizedStyle.addAttribute(STYLE_TYPE, STYLE_CHARACTER); + } else if (sectionStyle) { + realizedStyle.addAttributes(currentSectionAttributes()); + realizedStyle.addAttribute(STYLE_TYPE, STYLE_SECTION); + } else { /* must be a paragraph style */ + realizedStyle.addAttributes(currentParagraphAttributes()); + realizedStyle.addAttribute(STYLE_TYPE, STYLE_PARAGRAPH); + } + + if (nextStyle != STYLENUMBER_NONE) { + StyleDefiningDestination styleDest; + styleDest = definedStyles.get(Integer.valueOf(nextStyle)); + if (styleDest != null) { + next = styleDest.realize(); + } + } + + if (next != null) { + realizedStyle.addAttribute(STYLE_NEXT, next); + } + realizedStyle.addAttribute(STYLE_ADDITIVE, Boolean.valueOf(additive)); + realizedStyle.addAttribute(STYLE_HIDDEN, Boolean.valueOf(hidden)); + + return realizedStyle; + } + } + } + + /** + * An abstract RTF destination which simply tracks the attributes specified by the RTF control words + * in internal form and can produce acceptable attribute sets for the + * current character, paragraph, and section attributes. + * It is up to the subclasses to determine what is done with the actual text. + */ + abstract class AttributeTrackingDestination extends Destination { + @Override + public abstract void handleText(String text); + + /** This is the "chr" element of parserState, cached for more efficient use */ + private AttrSet characterAttributes; + /** This is the "pgf" element of parserState, cached for more efficient use */ + private AttrSet paragraphAttributes; + /** This is the "sec" element of parserState, cached for more efficient use */ + private AttrSet sectionAttributes; + + public AttributeTrackingDestination() { + characterAttributes = rootCharacterAttributes(); + parserState.put("chr", characterAttributes); + paragraphAttributes = rootParagraphAttributes(); + parserState.put("pgf", paragraphAttributes); + sectionAttributes = rootSectionAttributes(); + parserState.put("sec", sectionAttributes); + } + + @Override + public void begingroup() { + AttrSet characterParent = currentTextAttributes(); + AttrSet paragraphParent = currentParagraphAttributes(); + AttrSet sectionParent = currentSectionAttributes(); + + /* update the cached attribute dictionaries */ + characterAttributes = new AttrSet(); + characterAttributes.addAttributes(characterParent); + parserState.put("chr", characterAttributes); + + paragraphAttributes = new AttrSet(); + paragraphAttributes.addAttributes(paragraphParent); + parserState.put("pgf", paragraphAttributes); + + sectionAttributes = new AttrSet(); + sectionAttributes.addAttributes(sectionParent); + parserState.put("sec", sectionAttributes); + } + + @Override + public void endgroup(Map oldState) { + characterAttributes = (AttrSet)parserState.get("chr"); + paragraphAttributes = (AttrSet)parserState.get("pgf"); + sectionAttributes = (AttrSet)parserState.get("sec"); + } + + @Override + public boolean handleKeyword(String keyword) { + if (keyword.equals("ulnone")) { + return handleKeyword("ul", 0); + } + + { + RTFAttribute attr = straightforwardAttributes.get(keyword); + if (attr != null) { + boolean ok; + + switch (attr.domain()) { + case RTFAttribute.D_CHARACTER: + ok = attr.set(characterAttributes); + break; + case RTFAttribute.D_PARAGRAPH: + ok = attr.set(paragraphAttributes); + break; + case RTFAttribute.D_SECTION: + ok = attr.set(sectionAttributes); + break; + case RTFAttribute.D_META: + holder.backing = parserState; + ok = attr.set(holder); + holder.backing = null; + break; + case RTFAttribute.D_DOCUMENT: + ok = attr.set(documentAttributes); + break; + default: + // should never happen + ok = false; + break; + } + if (ok) { + return true; + } + } + } + + switch (keyword) { + case "plain": + resetCharacterAttributes(); + return true; + case "pard": + resetParagraphAttributes(); + return true; + case "sectd": + resetSectionAttributes(); + return true; + } + + return false; + } + + @Override + public boolean handleKeyword(String keyword, int parameter) { + if (keyword.equals("fc")) { + keyword = "cf"; + } + + switch (keyword) { + case "f": + parserState.put(keyword, Integer.valueOf(parameter)); + return true; + case "cf": + parserState.put(keyword, Integer.valueOf(parameter)); + return true; + case "cb": + parserState.put(keyword, Integer.valueOf(parameter)); + return true; + } + + { + RTFAttribute attr = straightforwardAttributes.get(keyword); + if (attr != null) { + boolean ok; + + switch (attr.domain()) { + case RTFAttribute.D_CHARACTER: + ok = attr.set(characterAttributes, parameter); + break; + case RTFAttribute.D_PARAGRAPH: + ok = attr.set(paragraphAttributes, parameter); + break; + case RTFAttribute.D_SECTION: + ok = attr.set(sectionAttributes, parameter); + break; + case RTFAttribute.D_META: + holder.backing = parserState; + ok = attr.set(holder, parameter); + holder.backing = null; + break; + case RTFAttribute.D_DOCUMENT: + ok = attr.set(documentAttributes, parameter); + break; + default: + // should never happen + ok = false; + break; + } + if (ok) { + return true; + } + } + } + + /* TODO: superscript/subscript */ + + // TODO +// if (keyword.equals("sl")) { +// if (parameter == 1000) { /* magic value! */ +// characterAttributes.removeAttribute(StyleConstants.LineSpacing); +// } else { +// /* TODO: The RTF sl attribute has special meaning if it's +// negative. Make sure that SwingText has the same special +// meaning, or find a way to imitate that. When SwingText +// handles this, also recognize the slmult keyword. */ +// StyleConstants.setLineSpacing(characterAttributes, parameter / 20f); +// } +// return true; +// } + + /* TODO: Other kinds of underlining */ + +// if (keyword.equals("tx") || keyword.equals("tb")) { +// float tabPosition = parameter / 20f; +// int tabAlignment, tabLeader; +// Number item; +// +// tabAlignment = TabStop.ALIGN_LEFT; +// item = (Number)(parserState.get("tab_alignment")); +// if (item != null) +// tabAlignment = item.intValue(); +// tabLeader = TabStop.LEAD_NONE; +// item = (Number)(parserState.get("tab_leader")); +// if (item != null) +// tabLeader = item.intValue(); +// if (keyword.equals("tb")) +// tabAlignment = TabStop.ALIGN_BAR; +// +// parserState.remove("tab_alignment"); +// parserState.remove("tab_leader"); +// +// TabStop newStop = new TabStop(tabPosition, tabAlignment, tabLeader); +// Dictionary tabs; +// Integer stopCount; +// +// @SuppressWarnings("unchecked") +// Dictionary tmp = (Dictionary)parserState.get("_tabs"); +// tabs = tmp; +// if (tabs == null) { +// tabs = new Hashtable(); +// parserState.put("_tabs", tabs); +// stopCount = Integer.valueOf(1); +// } else { +// stopCount = (Integer)tabs.get("stop count"); +// stopCount = Integer.valueOf(1 + stopCount.intValue()); +// } +// tabs.put(stopCount, newStop); +// tabs.put("stop count", stopCount); +// parserState.remove("_tabs_immutable"); +// +// return true; +// } + + switch (keyword) { + case "fs": + characterAttributes.addAttribute(StyleAttrs.FONT_SIZE, (parameter / 2)); + return true; + } + + if (keyword.equals("s") && paragraphStyles != null) { + parserState.put("paragraphStyle", paragraphStyles.get(parameter)); + return true; + } + + if (keyword.equals("cs") && characterStyles != null) { + parserState.put("characterStyle", characterStyles.get(parameter)); + return true; + } + + if (keyword.equals("ds") && sectionStyles != null) { + parserState.put("sectionStyle", sectionStyles.get(parameter)); + return true; + } + + return false; + } + + /** Returns a new AttrSet containing the + * default character attributes */ + protected AttrSet rootCharacterAttributes() { + AttrSet a = new AttrSet(); + /* TODO: default font */ + a.setItalic(false); + a.setBold(false); + a.setUnderline(false); + a.setForeground(Color.BLACK); + return a; + } + + /** Returns a new AttrSet containing the + * default paragraph attributes */ + protected AttrSet rootParagraphAttributes() { + AttrSet a = new AttrSet(); + a.setLeftIndent(0.0); + a.setRightIndent(0.0); + a.setFirstLineIndent(0.0); + a.setResolveParent(getDefaultStyle()); + return a; + } + + /** Returns a new AttrSet containing the + * default section attributes */ + protected AttrSet rootSectionAttributes() { + AttrSet a = new AttrSet(); + return a; + } + + /** + * Calculates the current text (character) attributes in a form suitable + * for SwingText from the current parser state. + * + * @return a new AttrSet containing the text attributes. + */ + AttrSet currentTextAttributes() { + AttrSet attributes = new AttrSet(characterAttributes); + + /* figure out the font name */ + /* TODO: catch exceptions for undefined attributes, + bad font indices, etc.? (as it stands, it is the caller's + job to clean up after corrupt RTF) */ + Integer fontnum = (Integer)parserState.get("f"); + /* note setFontFamily() can not handle a null font */ + String fontFamily; + if (fontnum != null) { + fontFamily = fontTable.get(fontnum); + } else { + fontFamily = null; + } + attributes.setFontFamily(fontFamily); + + if (colorTable != null) { + Integer stateItem = (Integer)parserState.get("cf"); + if (stateItem != null) { + Color fg = colorTable[stateItem.intValue()]; + attributes.setForeground(fg); + } else { + attributes.setForeground(null); + } + } + + if (colorTable != null) { + Integer stateItem = (Integer)parserState.get("cb"); + if (stateItem != null) { + Color bg = colorTable[stateItem.intValue()]; + attributes.setBackground(bg); + } else { + attributes.setBackground(null); + } + } + + Style characterStyle = (Style)parserState.get("characterStyle"); + if (characterStyle != null) { + attributes.setResolveParent(characterStyle); + } + + /* Other attributes are maintained directly in "attributes" */ + + return attributes; + } + + /** + * Calculates the current paragraph attributes (with keys + * as given in StyleConstants) from the current parser state. + * + * @return a newly created AttrSet. + */ + AttrSet currentParagraphAttributes() { + /* NB if there were a mutableCopy() method we should use it */ + AttrSet a = new AttrSet(paragraphAttributes); + + /*** Tab stops ***/ +// TabStop[] tabs = (TabStop[])parserState.get("_tabs_immutable"); +// if (tabs == null) { +// @SuppressWarnings("unchecked") +// Dictionary workingTabs = (Dictionary)parserState.get("_tabs"); +// if (workingTabs != null) { +// int count = ((Integer)workingTabs.get("stop count")).intValue(); +// tabs = new TabStop[count]; +// for (int ix = 1; ix <= count; ix++) +// tabs[ix - 1] = (TabStop)workingTabs.get(Integer.valueOf(ix)); +// parserState.put("_tabs_immutable", tabs); +// } +// } +// if (tabs != null) { +// a.addAttribute(Constants.Tabs, tabs); +// } + + Style paragraphStyle = (Style)parserState.get("paragraphStyle"); + if (paragraphStyle != null) { + a.setResolveParent(paragraphStyle); + } + + return a; + } + + /** + * Calculates the current section attributes + * from the current parser state. + * + * @return a newly created AttrSet. + */ + public AttrSet currentSectionAttributes() { + AttrSet attributes = new AttrSet(sectionAttributes); + + Style sectionStyle = (Style)parserState.get("sectionStyle"); + if (sectionStyle != null) { + attributes.setResolveParent(sectionStyle); + } + + return attributes; + } + + /** Resets the filter's internal notion of the current character + * attributes to their default values. Invoked to handle the + * \plain keyword. */ + protected void resetCharacterAttributes() { + handleKeyword("f", 0); + handleKeyword("cf", 0); + handleKeyword("fs", 24); /* 12 pt. */ + + for (RTFAttribute attr : straightforwardAttributes.values()) { + if (attr.domain() == RTFAttribute.D_CHARACTER) { + attr.setDefault(characterAttributes); + } + } + + handleKeyword("sl", 1000); + + parserState.remove("characterStyle"); + } + + /** Resets the filter's internal notion of the current paragraph's + * attributes to their default values. Invoked to handle the + * \pard keyword. */ + protected void resetParagraphAttributes() { + parserState.remove("_tabs"); + parserState.remove("_tabs_immutable"); + parserState.remove("paragraphStyle"); + + paragraphAttributes.setAlignment(TextAlignment.LEFT); + + for (RTFAttribute attr : straightforwardAttributes.values()) { + if (attr.domain() == RTFAttribute.D_PARAGRAPH) { + attr.setDefault(characterAttributes); + } + } + } + + /** Resets the filter's internal notion of the current section's + * attributes to their default values. Invoked to handle the + * \sectd keyword. */ + protected void resetSectionAttributes() { + for (RTFAttribute attr : straightforwardAttributes.values()) { + if (attr.domain() == RTFAttribute.D_SECTION) { + attr.setDefault(characterAttributes); + } + } + + parserState.remove("sectionStyle"); + } + } + + /** + * This Destination accumulates the styled segments within this reader. + */ + class DocumentDestination extends AttributeTrackingDestination { + /** true if the reader has not just finished a paragraph; false upon startup */ + private boolean inParagraph; + + public DocumentDestination() { + } + + public void deliverText(String text, AttrSet characterAttributes) { + StyleAttrs a = characterAttributes.getStyleAttrs(); + StyledSegment seg = StyledSegment.of(text, a); + segments.add(seg); + } + + public void finishParagraph(AttrSet pgfAttributes, AttrSet chrAttributes) { + // characterAttributes are ignored here + // TODO we could supply paragraph attributes either + // with a special StyledSegment (before the paragraph starts), or + // as a part of insertLineBreak. but for now, let's ignore them all + // TODO pgfAttributes + segments.add(StyledSegment.LINE_BREAK); + } + + protected void endSection() { + // no-op + } + + @Override + public void handleText(String text) { + if (!inParagraph) { + beginParagraph(); + } + deliverText(text, currentTextAttributes()); + } + + @Override + public void close() { + if (inParagraph) { + endParagraph(); + } + super.close(); + } + + @Override + public boolean handleKeyword(String keyword) { + switch (keyword) { + case "\r": + case "\n": + case "par": + endParagraph(); + return true; + case "sect": + endSection(); + return true; + } + + return super.handleKeyword(keyword); + } + + protected void beginParagraph() { + inParagraph = true; + } + + protected void endParagraph() { + AttrSet pgfAttributes = currentParagraphAttributes(); + AttrSet chrAttributes = currentTextAttributes(); + finishParagraph(pgfAttributes, chrAttributes); + inParagraph = false; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/Style.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/Style.java new file mode 100644 index 00000000000..2e659692df2 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/Style.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich.rtf; + +/** + * Replacement for javax.swing.text.Style. + */ +class Style extends AttrSet { + /** Default constructor. */ + public Style() { + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/NeXT.txt b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/NeXT.txt new file mode 100644 index 00000000000..0b763563e6f --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/NeXT.txt @@ -0,0 +1,33 @@ +/* the character set used for the \ansi control word in NeXT-rtf mode */ +0 1 2 3 4 5 6 7 +8 9 0 11 12 0 14 15 +16 17 18 19 20 21 22 23 +24 25 26 27 28 29 30 31 +32 33 34 35 36 37 38 39 +40 41 42 43 44 45 46 47 +48 49 50 51 52 53 54 55 +56 57 58 59 60 61 62 63 +64 65 66 67 68 69 70 71 +72 73 74 75 76 77 78 79 +80 81 82 83 84 85 86 87 +88 89 90 91 0 93 94 95 +96 97 98 99 100 101 102 103 +104 105 106 107 108 109 110 111 +112 113 114 115 116 117 118 119 +120 121 122 0 124 0 126 127 +160 192 193 194 195 196 197 199 +200 201 202 203 204 205 206 207 +208 209 210 211 212 213 214 217 +218 219 220 221 222 181 215 247 +169 161 162 163 8260 165 402 167 +164 8217 8220 171 8249 8250 64257 64258 +174 8211 8224 8225 183 166 182 8226 +8218 8222 8221 187 8230 8240 172 191 +185 715 180 710 732 175 728 729 +168 178 730 184 179 733 731 711 +8212 177 188 189 190 224 225 226 +227 228 229 231 232 233 234 235 +236 198 237 170 238 239 240 241 +321 216 338 186 242 243 244 245 +246 230 249 250 251 305 252 253 +322 248 339 223 254 255 0 0 diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/ansi.txt b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/ansi.txt new file mode 100644 index 00000000000..c32fe6e77a0 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/ansi.txt @@ -0,0 +1,38 @@ +# The character set used to read documents with the \ansi control +# word. The default "ansi" character set doesn't seem to be defined +# anywhere; this table is derived from the behavior of MSWord97 +# and some guesswork. For the most part it corresponds to +# ISO 8859 Latin-1. +0 1 2 3 4 5 6 7 +8 9 10 11 12 13 14 15 +16 17 18 19 20 21 22 23 +24 25 26 27 28 29 30 31 +32 33 34 35 36 37 38 39 +40 41 42 43 44 45 46 47 +48 49 50 51 52 53 54 55 +56 57 58 59 60 61 62 63 +64 65 66 67 68 69 70 71 +72 73 74 75 76 77 78 79 +80 81 82 83 84 85 86 87 +88 89 90 91 92 93 94 95 +96 97 98 99 100 101 102 103 +104 105 106 107 108 109 110 111 +112 113 114 115 116 117 118 119 +120 121 122 123 124 125 126 127 + +1026 1027 8218 402 8222 8230 8224 8225 +710 8240 352 8249 346 356 381 377 +1106 0 0 0 0 0 0 0 +0 8482 353 8250 347 357 382 378 +0 161 162 163 164 165 166 167 +168 169 170 171 172 173 174 175 +176 177 178 179 180 181 182 183 +184 185 186 187 188 189 190 191 +192 193 194 195 196 197 198 199 +200 201 202 203 204 205 206 207 +208 209 210 211 212 213 214 215 +216 217 218 219 220 221 222 223 +224 225 226 227 228 229 230 231 +232 233 234 235 236 237 238 239 +240 241 242 243 244 245 246 247 +248 249 250 251 252 253 254 255 diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/cpg437.txt b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/cpg437.txt new file mode 100644 index 00000000000..89b97813086 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/cpg437.txt @@ -0,0 +1,45 @@ +/* IBM/Microsoft Code Page 437 character set */ +/* Derived from tables on ftp.unicode.org */ +/* Original header: +# +# Name: cp437_DOSLatinUS to Unicode table +# Unicode version: 2.0 +# Table version: 2.00 +# Table format: Format A +# Date: 04/24/96 +# Authors: Lori Brownell +# K.D. Chang +# General notes: none +*/ +0 1 2 3 4 5 6 7 +8 9 10 11 12 13 14 15 +16 17 18 19 20 21 22 23 +24 25 26 27 28 29 30 31 +32 33 34 35 36 37 38 39 +40 41 42 43 44 45 46 47 +48 49 50 51 52 53 54 55 +56 57 58 59 60 61 62 63 +64 65 66 67 68 69 70 71 +72 73 74 75 76 77 78 79 +80 81 82 83 84 85 86 87 +88 89 90 91 92 93 94 95 +96 97 98 99 100 101 102 103 +104 105 106 107 108 109 110 111 +112 113 114 115 116 117 118 119 +120 121 122 123 124 125 126 127 +199 252 233 226 228 224 229 231 +234 235 232 239 238 236 196 197 +201 230 198 244 246 242 251 249 +255 214 220 162 163 165 8359 402 +225 237 243 250 241 209 170 186 +191 8976 172 189 188 161 171 187 +9617 9618 9619 9474 9508 9569 9570 9558 +9557 9571 9553 9559 9565 9564 9563 9488 +9492 9524 9516 9500 9472 9532 9566 9567 +9562 9556 9577 9574 9568 9552 9580 9575 +9576 9572 9573 9561 9560 9554 9555 9579 +9578 9496 9484 9608 9604 9612 9616 9600 +945 223 915 960 931 963 181 964 +934 920 937 948 8734 966 949 8745 +8801 177 8805 8804 8992 8993 247 8776 +176 8729 183 8730 8319 178 9632 160 diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/cpg850.txt b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/cpg850.txt new file mode 100644 index 00000000000..3f42a091a52 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/cpg850.txt @@ -0,0 +1,44 @@ +/* IBM/Microsoft Code Page 850 character set */ +/* derived form tables on ftp.unicode.org */ +/* Original header: +# Name: cp850_DOSLatin1 to Unicode table +# Unicode version: 2.0 +# Table version: 2.00 +# Table format: Format A +# Date: 04/24/96 +# Authors: Lori Brownell +# K.D. Chang +# General notes: none +*/ +0 1 2 3 4 5 6 7 +8 9 10 11 12 13 14 15 +16 17 18 19 20 21 22 23 +24 25 26 27 28 29 30 31 +32 33 34 35 36 37 38 39 +40 41 42 43 44 45 46 47 +48 49 50 51 52 53 54 55 +56 57 58 59 60 61 62 63 +64 65 66 67 68 69 70 71 +72 73 74 75 76 77 78 79 +80 81 82 83 84 85 86 87 +88 89 90 91 92 93 94 95 +96 97 98 99 100 101 102 103 +104 105 106 107 108 109 110 111 +112 113 114 115 116 117 118 119 +120 121 122 123 124 125 126 127 +199 252 233 226 228 224 229 231 +234 235 232 239 238 236 196 197 +201 230 198 244 246 242 251 249 +255 214 220 248 163 216 215 402 +225 237 243 250 241 209 170 186 +191 174 172 189 188 161 171 187 +9617 9618 9619 9474 9508 193 194 192 +169 9571 9553 9559 9565 162 165 9488 +9492 9524 9516 9500 9472 9532 227 195 +9562 9556 9577 9574 9568 9552 9580 164 +240 208 202 203 200 305 205 206 +207 9496 9484 9608 9604 166 204 9600 +211 223 212 210 245 213 181 254 +222 218 219 217 253 221 175 180 +173 177 8215 190 182 167 247 184 +176 168 183 185 179 178 9632 160 diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/mac.txt b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/mac.txt new file mode 100644 index 00000000000..adea5646e0f --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/rtf/charsets/mac.txt @@ -0,0 +1,43 @@ +/* MS RTF MacRoman character set */ +/* Derived from tables on ftp.unicode.org */ +/* original header: follows: */ +# Name: cp10000_MacRoman to Unicode table +# Unicode version: 2.0 +# Table version: 2.00 +# Table format: Format A +# Date: 04/24/96 +# Authors: Lori Brownell +# K.D. Chang +# General notes: none +0 1 2 3 4 5 6 7 +8 9 10 11 12 13 14 15 +16 17 18 19 20 21 22 23 +24 25 26 27 28 29 30 31 +32 33 34 35 36 37 38 39 +40 41 42 43 44 45 46 47 +48 49 50 51 52 53 54 55 +56 57 58 59 60 61 62 63 +64 65 66 67 68 69 70 71 +72 73 74 75 76 77 78 79 +80 81 82 83 84 85 86 87 +88 89 90 91 92 93 94 95 +96 97 98 99 100 101 102 103 +104 105 106 107 108 109 110 111 +112 113 114 115 116 117 118 119 +120 121 122 123 124 125 126 127 +196 197 199 201 209 214 220 225 +224 226 228 227 229 231 233 232 +234 235 237 236 238 239 241 243 +242 244 246 245 250 249 251 252 +8224 176 162 163 167 8226 182 223 +174 169 8482 180 168 8800 198 216 +8734 177 8804 8805 165 181 8706 8721 +8719 960 8747 170 186 8486 230 248 +191 161 172 8730 402 8776 8710 171 +187 8230 160 192 195 213 338 339 +8211 8212 8220 8221 8216 8217 247 9674 +255 376 8260 164 8249 8250 64257 64258 +8225 183 8218 8222 8240 194 202 193 +203 200 205 206 207 204 211 212 +0 210 218 219 217 305 710 732 +175 728 729 730 184 733 731 711 diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/D.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/D.java new file mode 100644 index 00000000000..ee1dcdf01f4 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/D.java @@ -0,0 +1,55 @@ +package com.sun.jfx.incubator.scene.control.rich.util; + +import java.text.DecimalFormat; + +/** debugging aid */ +public class D { + private static final DecimalFormat DOUBLE_FORMAT = new DecimalFormat("0.###"); + + public static void p(Object... a) { + StringBuilder sb = new StringBuilder(); + for (Object x : a) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(x); + } + withCaller(2, sb.toString()); + } + + private static void withCaller(int level, String msg) { + StackTraceElement t = new Throwable().getStackTrace()[level]; + String className = t.getClassName(); + int ix = className.lastIndexOf('.'); + if (ix >= 0) { + className = className.substring(ix + 1); + } + System.err.println(className + "." + t.getMethodName() + ":" + t.getLineNumber() + " " + msg); + } + + public static void trace() { + new Error("Stack Trace:").printStackTrace(); + } + + public static String f(double v) { + return DOUBLE_FORMAT.format(v); + } + + public static SW sw() { + return new SW(); + } + + /** stop watch */ + public static class SW { + private final long start = System.nanoTime(); + + public SW() { + } + + @Override + public String toString() { + double ms = (System.nanoTime() - start) / 1_000_000_000.0; + return f(ms); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/IDisconnectable.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/IDisconnectable.java new file mode 100644 index 00000000000..e494b40d2c7 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/IDisconnectable.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// Original code is re-licensed to Oracle by the author. +// https://github.com/andy-goryachev/FxTextEditor/blob/master/src/goryachev/common/util/Disconnectable.java +// Copyright © 2021-2022 Andy Goryachev +package com.sun.jfx.incubator.scene.control.rich.util; + +/** + * A functional interface that provides a {@link #disconnect()} method. + */ +@FunctionalInterface +public interface IDisconnectable { + /** + * Disconnects what has been connected. May be called multiple times, only the + * first invocation actually disconnects. + */ + public void disconnect(); +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/ImgUtil.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/ImgUtil.java new file mode 100644 index 00000000000..ca9cd65098d --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/ImgUtil.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.sun.jfx.incubator.scene.control.rich.util; + +import java.awt.AlphaComposite; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.awt.image.SampleModel; +import java.awt.image.SinglePixelPackedSampleModel; +import java.nio.IntBuffer; +import javafx.scene.image.Image; +import javafx.scene.image.PixelFormat; +import javafx.scene.image.PixelReader; +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import javafx.scene.image.WritablePixelFormat; +import javafx.scene.paint.Color; + +/** + * Image Utilities copied from javafx.embed.swing.SwingFXUtils. + * Avoids creating dependency on javafx.swing to javafx.controls. + */ +public class ImgUtil { + private ImgUtil() { } // no instances + + /** + * Snapshots the specified {@link BufferedImage} and stores a copy of + * its pixels into a JavaFX {@link Image} object, creating a new + * object if needed. + * The returned {@code Image} will be a static snapshot of the state + * of the pixels in the {@code BufferedImage} at the time the method + * completes. Further changes to the {@code BufferedImage} will not + * be reflected in the {@code Image}. + *

+ * The optional JavaFX {@link WritableImage} parameter may be reused + * to store the copy of the pixels. + * A new {@code Image} will be created if the supplied object is null, + * is too small or of a type which the image pixels cannot be easily + * converted into. + * + * @param bimg the {@code BufferedImage} object to be converted + * @param wimg an optional {@code WritableImage} object that can be + * used to store the returned pixel data + * @return an {@code Image} object representing a snapshot of the + * current pixels in the {@code BufferedImage}. + * @since JavaFX 2.2 + */ + public static WritableImage toFXImage(BufferedImage bimg, WritableImage wimg) { + int bw = bimg.getWidth(); + int bh = bimg.getHeight(); + switch (bimg.getType()) { + case BufferedImage.TYPE_INT_ARGB: + case BufferedImage.TYPE_INT_ARGB_PRE: + break; + default: + BufferedImage converted = + new BufferedImage(bw, bh, BufferedImage.TYPE_INT_ARGB_PRE); + Graphics2D g2d = converted.createGraphics(); + g2d.drawImage(bimg, 0, 0, null); + g2d.dispose(); + bimg = converted; + break; + } + // assert(bimg.getType == TYPE_INT_ARGB[_PRE]); + if (wimg != null) { + int iw = (int) wimg.getWidth(); + int ih = (int) wimg.getHeight(); + if (iw < bw || ih < bh) { + wimg = null; + } else if (bw < iw || bh < ih) { + int empty[] = new int[iw]; + PixelWriter pw = wimg.getPixelWriter(); + PixelFormat pf = PixelFormat.getIntArgbPreInstance(); + if (bw < iw) { + pw.setPixels(bw, 0, iw-bw, bh, pf, empty, 0, 0); + } + if (bh < ih) { + pw.setPixels(0, bh, iw, ih-bh, pf, empty, 0, 0); + } + } + } + if (wimg == null) { + wimg = new WritableImage(bw, bh); + } + PixelWriter pw = wimg.getPixelWriter(); + DataBufferInt db = (DataBufferInt)bimg.getRaster().getDataBuffer(); + int data[] = db.getData(); + int offset = bimg.getRaster().getDataBuffer().getOffset(); + int scan = 0; + SampleModel sm = bimg.getRaster().getSampleModel(); + if (sm instanceof SinglePixelPackedSampleModel) { + scan = ((SinglePixelPackedSampleModel)sm).getScanlineStride(); + } + + PixelFormat pf = (bimg.isAlphaPremultiplied() ? + PixelFormat.getIntArgbPreInstance() : + PixelFormat.getIntArgbInstance()); + pw.setPixels(0, 0, bw, bh, pf, data, offset, scan); + return wimg; + } + + /** + * Determine the optimal BufferedImage type to use for the specified + * {@code fxFormat} allowing for the specified {@code bimg} to be used + * as a potential default storage space if it is not null and is compatible. + * + * @param fxFormat the PixelFormat of the source FX Image + * @param bimg an optional existing {@code BufferedImage} to be used + * for storage if it is compatible, or null + * @return + */ + static int + getBestBufferedImageType(PixelFormat fxFormat, BufferedImage bimg, + boolean isOpaque) + { + if (bimg != null) { + int bimgType = bimg.getType(); + if (bimgType == BufferedImage.TYPE_INT_ARGB || + bimgType == BufferedImage.TYPE_INT_ARGB_PRE || + (isOpaque && + (bimgType == BufferedImage.TYPE_INT_BGR || + bimgType == BufferedImage.TYPE_INT_RGB))) + { + // We will allow the caller to give us a BufferedImage + // that has an alpha channel, but we might not otherwise + // construct one ourselves. + // We will also allow them to choose their own premultiply + // type which may not match the image. + // If left to our own devices we might choose a more specific + // format as indicated by the choices below. + return bimgType; + } + } + switch (fxFormat.getType()) { + default: + case BYTE_BGRA_PRE: + case INT_ARGB_PRE: + return BufferedImage.TYPE_INT_ARGB_PRE; + case BYTE_BGRA: + case INT_ARGB: + return BufferedImage.TYPE_INT_ARGB; + case BYTE_RGB: + return BufferedImage.TYPE_INT_RGB; + case BYTE_INDEXED: + return (fxFormat.isPremultiplied() + ? BufferedImage.TYPE_INT_ARGB_PRE + : BufferedImage.TYPE_INT_ARGB); + } + } + + /** + * Determine the appropriate {@link WritablePixelFormat} type that can + * be used to transfer data into the indicated BufferedImage. + * + * @param bimg the BufferedImage that will be used as a destination for + * a {@code PixelReader#getPixels()} operation. + * @return + */ + private static WritablePixelFormat + getAssociatedPixelFormat(BufferedImage bimg) + { + switch (bimg.getType()) { + // We lie here for xRGB, but we vetted that the src data was opaque + // so we can ignore the alpha. We use ArgbPre instead of Argb + // just to get a loop that does not have divides in it if the + // PixelReader happens to not know the data is opaque. + case BufferedImage.TYPE_INT_RGB: + case BufferedImage.TYPE_INT_ARGB_PRE: + return PixelFormat.getIntArgbPreInstance(); + case BufferedImage.TYPE_INT_ARGB: + return PixelFormat.getIntArgbInstance(); + default: + // Should not happen... + throw new InternalError("Failed to validate BufferedImage type"); + } + } + + private static boolean checkFXImageOpaque(PixelReader pr, int iw, int ih) { + for (int x = 0; x < iw; x++) { + for (int y = 0; y < ih; y++) { + Color color = pr.getColor(x,y); + if (color.getOpacity() != 1.0) { + return false; + } + } + } + return true; + } + + /** + * Snapshots the specified JavaFX {@link Image} object and stores a + * copy of its pixels into a {@link BufferedImage} object, creating + * a new object if needed. + * The method will only convert a JavaFX {@code Image} that is readable + * as per the conditions on the + * {@link Image#getPixelReader() Image.getPixelReader()} + * method. + * If the {@code Image} is not readable, as determined by its + * {@code getPixelReader()} method, then this method will return null. + * If the {@code Image} is a writable, or other dynamic image, then + * the {@code BufferedImage} will only be set to the current state of + * the pixels in the image as determined by its {@link PixelReader}. + * Further changes to the pixels of the {@code Image} will not be + * reflected in the returned {@code BufferedImage}. + *

+ * The optional {@code BufferedImage} parameter may be reused to store + * the copy of the pixels. + * A new {@code BufferedImage} will be created if the supplied object + * is null, is too small or of a type which the image pixels cannot + * be easily converted into. + * + * @param img the JavaFX {@code Image} to be converted + * @param bimg an optional {@code BufferedImage} object that may be + * used to store the returned pixel data + * @return a {@code BufferedImage} containing a snapshot of the JavaFX + * {@code Image}, or null if the {@code Image} is not readable. + * @since JavaFX 2.2 + */ + public static BufferedImage fromFXImage(Image img, BufferedImage bimg) { + PixelReader pr = img.getPixelReader(); + if (pr == null) { + return null; + } + int iw = (int) img.getWidth(); + int ih = (int) img.getHeight(); + PixelFormat fxFormat = pr.getPixelFormat(); + boolean srcPixelsAreOpaque = false; + switch (fxFormat.getType()) { + case INT_ARGB_PRE: + case INT_ARGB: + case BYTE_BGRA_PRE: + case BYTE_BGRA: + // Check fx image opacity only if + // supplied BufferedImage is without alpha channel + if (bimg != null && + (bimg.getType() == BufferedImage.TYPE_INT_BGR || + bimg.getType() == BufferedImage.TYPE_INT_RGB)) { + srcPixelsAreOpaque = checkFXImageOpaque(pr, iw, ih); + } + break; + case BYTE_RGB: + srcPixelsAreOpaque = true; + break; + } + int prefBimgType = getBestBufferedImageType(pr.getPixelFormat(), bimg, srcPixelsAreOpaque); + if (bimg != null) { + int bw = bimg.getWidth(); + int bh = bimg.getHeight(); + if (bw < iw || bh < ih || bimg.getType() != prefBimgType) { + bimg = null; + } else if (iw < bw || ih < bh) { + Graphics2D g2d = bimg.createGraphics(); + g2d.setComposite(AlphaComposite.Clear); + g2d.fillRect(0, 0, bw, bh); + g2d.dispose(); + } + } + if (bimg == null) { + bimg = new BufferedImage(iw, ih, prefBimgType); + } + DataBufferInt db = (DataBufferInt)bimg.getRaster().getDataBuffer(); + int data[] = db.getData(); + int offset = bimg.getRaster().getDataBuffer().getOffset(); + int scan = 0; + SampleModel sm = bimg.getRaster().getSampleModel(); + if (sm instanceof SinglePixelPackedSampleModel) { + scan = ((SinglePixelPackedSampleModel)sm).getScanlineStride(); + } + + WritablePixelFormat pf = getAssociatedPixelFormat(bimg); + pr.getPixels(0, 0, iw, ih, pf, data, offset, scan); + return bimg; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/ListenerHelper.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/ListenerHelper.java new file mode 100644 index 00000000000..b3e07c85f1e --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/ListenerHelper.java @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// Original code is re-licensed to Oracle by the author. +// https://github.com/andy-goryachev/FxTextEditor/blob/master/src/goryachev/fx/FxDisconnector.java +// Copyright © 2021-2022 Andy Goryachev +package com.sun.jfx.incubator.scene.control.rich.util; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.function.Consumer; +import java.util.function.Function; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.collections.ListChangeListener; +import javafx.collections.MapChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.ObservableMap; +import javafx.collections.ObservableSet; +import javafx.collections.SetChangeListener; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.event.EventTarget; +import javafx.event.EventType; +import javafx.scene.control.SkinBase; + +/** + * This class provides convenience methods for adding various listeners, both + * strong and weak, as well as a single {@link #disconnect()} method to remove + * all listeners. + *

+ * There are two usage patterns: + *

    + *
  • Client code registers a number of listeners and removes them all at once + * via {@link #disconnect()} call. + *
  • Client code registers a number of listeners and removes one via its + * {@link IDisconnectable} instance. + *
+ * + * This class is currently used for clean replacement of {@link Skin}s. + * We should consider making this class a part of the public API in {@code javax.base}, + * since it proved itself useful in removing listeners and handlers in bulk at the application level. + */ +public class ListenerHelper implements IDisconnectable { + private WeakReference ownerRef; + private final ArrayList items = new ArrayList<>(4); + private static Function,ListenerHelper> accessor; + + public ListenerHelper(Object owner) { + ownerRef = new WeakReference<>(owner); + } + + public ListenerHelper() { + } + + public static void setAccessor(Function,ListenerHelper> a) { + accessor = a; + } + + public static ListenerHelper get(SkinBase skin) { + return accessor.apply(skin); + } + + public IDisconnectable addDisconnectable(Runnable r) { + IDisconnectable d = new IDisconnectable() { + @Override + public void disconnect() { + items.remove(this); + r.run(); + } + }; + items.add(d); + return d; + } + + @Override + public void disconnect() { + for (int i = items.size() - 1; i >= 0; i--) { + IDisconnectable d = items.remove(i); + d.disconnect(); + } + } + + private boolean isAliveOrDisconnect() { + if (ownerRef != null) { + if (ownerRef.get() == null) { + disconnect(); + return false; + } + } + return true; + } + + // change listeners + + public IDisconnectable addChangeListener(Runnable callback, ObservableValue... props) { + return addChangeListener(callback, false, props); + } + + public IDisconnectable addChangeListener(Runnable onChange, boolean fireImmediately, ObservableValue... props) { + if (onChange == null) { + throw new NullPointerException("onChange must not be null."); + } + + ChLi li = new ChLi() { + @Override + public void disconnect() { + for (ObservableValue p : props) { + p.removeListener(this); + } + items.remove(this); + } + + @Override + public void changed(ObservableValue p, Object oldValue, Object newValue) { + if (isAliveOrDisconnect()) { + onChange.run(); + } + } + }; + + items.add(li); + + for (ObservableValue p : props) { + p.addListener(li); + } + + if (fireImmediately) { + onChange.run(); + } + + return li; + } + + public IDisconnectable addChangeListener(ObservableValue prop, ChangeListener listener) { + return addChangeListener(prop, false, listener); + } + + public IDisconnectable addChangeListener(ObservableValue prop, boolean fireImmediately, ChangeListener listener) { + if (listener == null) { + throw new NullPointerException("Listener must be specified."); + } + + ChLi li = new ChLi() { + @Override + public void disconnect() { + prop.removeListener(this); + items.remove(this); + } + + @Override + public void changed(ObservableValue src, T oldValue, T newValue) { + if (isAliveOrDisconnect()) { + listener.changed(src, oldValue, newValue); + } + } + }; + + items.add(li); + prop.addListener(li); + + if (fireImmediately) { + T v = prop.getValue(); + listener.changed(prop, null, v); + } + + return li; + } + + public IDisconnectable addChangeListener(ObservableValue prop, Consumer callback) { + return addChangeListener(prop, false, callback); + } + + public IDisconnectable addChangeListener(ObservableValue prop, boolean fireImmediately, Consumer callback) { + if (callback == null) { + throw new NullPointerException("Callback must be specified."); + } + + ChLi li = new ChLi() { + @Override + public void disconnect() { + prop.removeListener(this); + items.remove(this); + } + + @Override + public void changed(ObservableValue observable, T oldValue, T newValue) { + if (isAliveOrDisconnect()) { + callback.accept(newValue); + } + } + }; + + items.add(li); + prop.addListener(li); + + if (fireImmediately) { + T v = prop.getValue(); + callback.accept(v); + } + + return li; + } + + // invalidation listeners + + public IDisconnectable addInvalidationListener(Runnable callback, ObservableValue... props) { + return addInvalidationListener(callback, false, props); + } + + public IDisconnectable addInvalidationListener(Runnable callback, boolean fireImmediately, ObservableValue... props) { + if (callback == null) { + throw new NullPointerException("Callback must be specified."); + } + + InLi li = new InLi() { + @Override + public void disconnect() { + for (ObservableValue p : props) { + p.removeListener(this); + } + items.remove(this); + } + + @Override + public void invalidated(Observable p) { + if (isAliveOrDisconnect()) { + callback.run(); + } + } + }; + + items.add(li); + + for (ObservableValue p : props) { + p.addListener(li); + } + + if (fireImmediately) { + callback.run(); + } + + return li; + } + + public IDisconnectable addInvalidationListener(ObservableValue prop, InvalidationListener listener) { + return addInvalidationListener(prop, false, listener); + } + + public IDisconnectable addInvalidationListener(ObservableValue prop, boolean fireImmediately, InvalidationListener listener) { + if (listener == null) { + throw new NullPointerException("Listener must be specified."); + } + + InLi li = new InLi() { + @Override + public void disconnect() { + prop.removeListener(this); + items.remove(this); + } + + @Override + public void invalidated(Observable observable) { + if (isAliveOrDisconnect()) { + listener.invalidated(observable); + } + } + }; + + items.add(li); + prop.addListener(li); + + if (fireImmediately) { + listener.invalidated(prop); + } + + return li; + } + + // list change listeners + + public IDisconnectable addListChangeListener(ObservableList list, ListChangeListener listener) { + if (listener == null) { + throw new NullPointerException("Listener must be specified."); + } + + LiChLi li = new LiChLi() { + @Override + public void disconnect() { + list.removeListener(this); + items.remove(this); + } + + @Override + public void onChanged(Change ch) { + if (isAliveOrDisconnect()) { + listener.onChanged(ch); + } + } + }; + + items.add(li); + list.addListener(li); + + return li; + } + + // map change listener + + public IDisconnectable addMapChangeListener(ObservableMap list, MapChangeListener listener) { + if (listener == null) { + throw new NullPointerException("Listener must be specified."); + } + + MaChLi li = new MaChLi() { + @Override + public void disconnect() { + list.removeListener(this); + items.remove(this); + } + + @Override + public void onChanged(Change ch) { + if (isAliveOrDisconnect()) { + listener.onChanged(ch); + } + } + }; + + items.add(li); + list.addListener(li); + + return li; + } + + // set change listeners + + public IDisconnectable addSetChangeListener(ObservableSet set, SetChangeListener listener) { + if (listener == null) { + throw new NullPointerException("Listener must be specified."); + } + + SeChLi li = new SeChLi() { + @Override + public void disconnect() { + set.removeListener(this); + items.remove(this); + } + + @Override + public void onChanged(Change ch) { + if (isAliveOrDisconnect()) { + listener.onChanged(ch); + } + } + }; + + items.add(li); + set.addListener(li); + + return li; + } + + // event handlers + + public IDisconnectable addEventHandler(EventTarget x, EventType t, EventHandler handler) { + EvHa h = new EvHa<>(handler) { + @Override + public void disconnect() { + x.removeEventHandler(t, this); + } + }; + + items.add(h); + + x.addEventHandler(t, h); + + return h; + } + + // event filters + + public IDisconnectable addEventFilter(EventTarget x, EventType t, EventHandler handler) { + EvHa h = new EvHa<>(handler) { + @Override + public void disconnect() { + x.removeEventFilter(t, this); + } + }; + + items.add(h); + + x.addEventFilter(t, h); + + return h; + } + + // + + private static abstract class ChLi implements IDisconnectable, ChangeListener { } + + private static abstract class InLi implements IDisconnectable, InvalidationListener { } + + private static abstract class LiChLi implements IDisconnectable, ListChangeListener { } + + private static abstract class MaChLi implements IDisconnectable, MapChangeListener { } + + private static abstract class SeChLi implements IDisconnectable, SetChangeListener { } + + private abstract class EvHa implements IDisconnectable, EventHandler { + private final EventHandler handler; + + public EvHa(EventHandler h) { + this.handler = h; + } + + @Override + public void handle(T ev) { + if (isAliveOrDisconnect()) { + handler.handle(ev); + } + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/RichUtils.java b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/RichUtils.java new file mode 100644 index 00000000000..fc5e049b59e --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/com/sun/jfx/incubator/scene/control/rich/util/RichUtils.java @@ -0,0 +1,513 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.sun.jfx.incubator.scene.control.rich.util; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.text.DecimalFormat; +import java.util.List; +import javax.imageio.ImageIO; +import javafx.application.ConditionalFeature; +import javafx.application.Platform; +import javafx.css.CssMetaData; +import javafx.css.Styleable; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.image.Image; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.scene.shape.LineTo; +import javafx.scene.shape.MoveTo; +import javafx.scene.shape.PathElement; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; +import javafx.scene.text.TextFlow; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * RichTextArea specific utility methods. + */ +public final class RichUtils { + + private static final DecimalFormat format = new DecimalFormat("#0.##"); + + private RichUtils() { + } + + /** + * A safe substring method which is tolerant to null text, and offsets being outside of the text boundaries. + * + * @param text source text or null + * @param start start offset, must be >= 0 + * @param end end offset + * @return a non-null substring + */ + public static String substring(String text, int start, int end) { + if (text == null) { + return ""; + } + + int len = text.length(); + if ((end < 0) || (end > len)) { + end = len; + } + + if ((start == 0) && (end == len)) { + return text; + } + + return text.substring(start, end); + } + + /** Converts Color to "#rrggbb" or "rgba(r,g,b,a)" string */ + public static String toCssColor(Color c) { + if (c.getOpacity() == 1.0) { + return String.format( + "#%02x%02x%02x", + eightBit(c.getRed()), + eightBit(c.getGreen()), + eightBit(c.getBlue()) + ); + } else { + return String.format( + "rgba(%d,%d,%d,%f)", + eightBit(c.getRed()), + eightBit(c.getGreen()), + eightBit(c.getBlue()), + c.getOpacity() + ); + } + } + + /* Converts Color to its web CSS value #rrggbb */ + public static String toWebColor(Color c) { + return String.format( + "#%02x%02x%02x", + eightBit(c.getRed()), + eightBit(c.getGreen()), + eightBit(c.getBlue()) + ); + } + + private static int eightBit(double val) { + int v = (int)Math.round(val * 255); + if (v < 0) { + return 0; + } else if (v > 255) { + return 255; + } + return v; + } + + /** null-tolerant !equals() */ + public static boolean notEquals(Object a, Object b) { + return !equals(a, b); + } + + /** null-tolerant equals() */ + public static boolean equals(Object a, Object b) { + if (a == b) { + return true; + } else if (a == null) { + return (b == null); + } else if (b == null) { + return false; + } else { + return a.equals(b); + } + } + + public static boolean isTouchSupported() { + return Platform.isSupported(ConditionalFeature.INPUT_TOUCH); + } + + public static int getTextLength(TextFlow f) { + int len = 0; + for (Node n : f.getChildrenUnmodifiable()) { + if (n instanceof Text t) { + len += t.getText().length(); + } else { + // treat non-Text nodes as having 1 character + len++; + } + } + return len; + } + + // TODO javadoc + // translates path elements from src frame of reference to target, with additional shift by dx, dy + // only MoveTo, LineTo are supported + // may return null + public static PathElement[] translatePath(Region tgt, Region src, PathElement[] elements, double deltax, double deltay) { + //System.out.println("translatePath from=" + dump(elements) + " dx=" + deltax + " dy=" + deltay); // FIX + Point2D ps = src.localToScreen(0.0, 0.0); + if (ps == null) { + return null; + } + + Point2D pt = tgt.localToScreen(tgt.snappedLeftInset(), tgt.snappedTopInset()); + double dx = ps.getX() - pt.getX() + deltax; + double dy = ps.getY() - pt.getY() + deltay; + //System.out.println("dx=" + dx + " dy=" + dy); // FIX + + for (int i = 0; i < elements.length; i++) { + PathElement em = elements[i]; + if (em instanceof LineTo m) { + em = new LineTo(m.getX() + dx, m.getY() + dy); + } else if (em instanceof MoveTo m) { + em = new MoveTo(m.getX() + dx, m.getY() + dy); + } else { + throw new RuntimeException("unexpected path element " + em); + } + + elements[i] = em; + } + //System.out.println("translatePath to=" + dump(elements)); // FIX + return elements; + } + + /** + * Returns true if the font family corresponds to a logical font as defined in + * OpenJFX Font Setup wiki. + * @param family the font family + * @return true if logical, false otherwise + */ + public static boolean isLogicalFont(String family) { + switch (family) { + case "System": + case "Serif": + case "SansSerif": + case "Monospaced": + return true; + } + return false; + } + + /** + * Guesses the font style from the font name, until JDK-8092191 is implemented. + * @param lowerCaseName font name, must be lowercase'd + * @return font style: [ normal | italic | oblique ] + */ + public static String guessFontStyle(String lowerCaseName) { + // are we going to encounter a localized font name? + if (lowerCaseName.contains("italic")) { + return "italic"; + } else if (lowerCaseName.contains("oblique")) { + return "oblique"; + } + return "normal"; + } + + /** + * Guesses the font weight from the font name, until JDK-8092191 is implemented. + * @param lowerCaseName font name, must be lowercase'd + * @return font weight: [ normal | bold | bolder | lighter | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 ] + */ + public static String guessFontWeight(String lowerCaseName) { + // are we going to encounter a localized font name? + if (lowerCaseName.contains("thin")) { + return "100"; + } else if (lowerCaseName.contains("extralight")) { + return "200"; + } else if (lowerCaseName.contains("light")) { + return "300"; + } else if (lowerCaseName.contains("medium")) { + return "500"; + } else if (lowerCaseName.contains("semibold")) { + return "600"; + } else if (lowerCaseName.contains("demibold")) { + return "600"; + } else if (lowerCaseName.contains("bold")) { + return "700"; + } else if (lowerCaseName.contains("extrabold")) { + return "800"; + } else if (lowerCaseName.contains("heavy")) { + return "900"; + } + return "normal"; // 400, see FontWeight + } + + /** + * Returns true if the specified lowercased font name is determined to be bold. + * This method is not guaranteed to work in any circumstances, see JDK-8092191 + * @param lowerCaseFontName the font name converted to lower case + * @return true if the font is bold + */ + public static boolean isBold(String lowerCaseFontName) { + // any others? + // non-english names? + return + lowerCaseFontName.contains("bold") || + lowerCaseFontName.contains("extrabold") || + lowerCaseFontName.contains("heavy"); + } + + /** + * Returns true if the specified lowercased font name is determined to be italic or oblique. + * This method is not guaranteed to work in any circumstances, see JDK-8092191 + * @param lowerCaseFontName the font name converted to lower case + * @return true if the font is italic + */ + public static boolean isItalic(String lowerCaseFontName) { + // any others? + // non-english names? + return + lowerCaseFontName.contains("italic") || + lowerCaseFontName.contains("oblique"); + } + + /** dumps the path element array to a compact human-readable string */ + public static String dump(PathElement[] elements) { + StringBuilder sb = new StringBuilder(); + if (elements == null) { + sb.append("null"); + } else { + for (PathElement em : elements) { + if (em instanceof MoveTo p) { + sb.append('M'); + sb.append(r(p.getX())); + sb.append(','); + sb.append(r(p.getY())); + sb.append(' '); + } else if (em instanceof LineTo p) { + sb.append('L'); + sb.append(r(p.getX())); + sb.append(','); + sb.append(r(p.getY())); + sb.append(' '); + } else { + sb.append(em); + sb.append(' '); + } + } + } + return sb.toString(); + } + + private static int r(double x) { + return (int)Math.round(x); + } + + public static String toCss(TextAlignment a) { + switch(a) { + case CENTER: + return "center"; + case JUSTIFY: + return "justify"; + case RIGHT: + return "right"; + case LEFT: + default: + return "left"; + } + } + + public static String formatDouble(Double value) { + return format.format(value); + } + + @Deprecated // FIX remove + public static char encodeAlignment(TextAlignment a) { + switch (a) { + case CENTER: + return 'C'; + case JUSTIFY: + return 'J'; + case RIGHT: + return 'R'; + case LEFT: + default: + return 'L'; + } + } + + @Deprecated // FIX remove + public static TextAlignment decodeAlignment(int c) throws IOException { + switch (c) { + case 'C': + return TextAlignment.CENTER; + case 'J': + return TextAlignment.JUSTIFY; + case 'L': + return TextAlignment.LEFT; + case 'R': + return TextAlignment.RIGHT; + default: + throw new IOException("failed parsing alignment (" + (char)c + ")"); + } + } + + /** + * Combines style attributes, returning combined object (or null). + * + * @param lowPri the low priority attributes + * @param hiPri the high priority attributes + * @return the combined attributes, or null + */ + public static StyleAttrs combine(StyleAttrs lowPri, StyleAttrs hiPri) { + if ((lowPri != null) && (!lowPri.isEmpty())) { + if (hiPri == null) { + return lowPri; + } else { + return StyleAttrs.builder().merge(lowPri).merge(hiPri).build(); + } + } + return hiPri; + } + + /** + * Utility method which combines {@code CssMetaData} items in one immutable list. + *

+ * The intended usage is to combine the parent and the child {@code CssMetaData} for + * the purposes of {@code getClassCssMetaData()} method, see for example {@link Node#getClassCssMetaData()}. + *

+ * Example: + *

{@code
+     * private static final List> STYLEABLES = CssMetaData.combine(
+     *      .getClassCssMetaData(),
+     *      STYLEABLE1,
+     *      STYLEABLE2
+     *  );
+     * }
+ * This method returns an instance of a {@code List} that implements + * {@link java.util.RandomAccess} interface. + * + * @param inheritedFromParent the {@code CssMetaData} items inherited from parent, must not be null + * @param items the additional items + * @return the immutable list containing all of the items + */ + // NOTE: this should be a public utility, see https://bugs.openjdk.org/browse/JDK-8320796 + public static List> combine( + List> inheritedFromParent, + CssMetaData... items) + { + CssMetaData[] combined = new CssMetaData[inheritedFromParent.size() + items.length]; + inheritedFromParent.toArray(combined); + System.arraycopy(items, 0, combined, inheritedFromParent.size(), items.length); + // makes a copy, unfortunately + return List.of(combined); + } + + /** + * Reads a UTF8 string from the input stream. + * This method does not close the input stream. + * @param in the input stream + * @return the string + * @throws IOException if an I/O error occurs + */ + public static String readString(InputStream in) throws IOException { + BufferedInputStream b = new BufferedInputStream(in); + InputStreamReader rd = new InputStreamReader(in, StandardCharsets.UTF_8); + StringBuilder sb = new StringBuilder(65536); + int c; + while ((c = in.read()) >= 0) { + sb.append((char)c); + } + return sb.toString(); + } + + /** + * Invoked when the user attempts an invalid operation, + * such as pasting into an uneditable TextInputControl + * that has focus. The default implementation beeps. + * + * @param originator the Node the error occurred in, may be null + * indicating the error condition is not directly associated with a Node + * @param error the exception thrown (can be null) + */ + // TODO this probably should be in Platform + public static void provideErrorFeedback(Node originator, Throwable error) { + beep(); + if (error != null) { + // TODO should be using logging + error.printStackTrace(); + } + } + + /** Emits a short audible alert, if supported by the platform. */ + public static void beep() { + // TODO not supported in FX + } + + /** + * Writes an Image to a byte array in PNG format. + * + * @param im source image + * @return byte array containing PNG image + * @throws IOException if an I/O error occurs + */ + public static byte[] writePNG(Image im) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(65536); + // this might conflict with user-set value + ImageIO.setUseCache(false); + ImageIO.write(ImgUtil.fromFXImage(im, null), "PNG", out); + return out.toByteArray(); + } + + /** + * Returns true if code point at the specified offset is a letter or a digit, + * returns false otherwise or if the offset is outside of the valid range. + * @param text the text + * @param offset the character offset + * @param len the text length + * @return true if the code point at the specified offset is a letter or a digit + */ + public static boolean isLetterOrDigit(String text, int offset) { + if (offset < 0) { + return false; + } else if (offset >= text.length()) { + return false; + } + // ignore the case when 'c' is a high surrogate without the low surrogate + int c = Character.codePointAt(text, offset); + return Character.isLetterOrDigit(c); + } + + /** + * Returns the offset of the next code point, or the end of the text string. + * @param text the text + * @param offset the offset to start from + * @return the offset of the next code point, or the end of the text string + */ + public static int nextCodePoint(String text, int offset) { + int len = text.length(); + if (offset < len) { + char ch1 = text.charAt(offset++); + if (Character.isHighSurrogate(ch1) && offset < len) { + char ch2 = text.charAt(offset); + if (Character.isLowSurrogate(ch2)) { + ++offset; + } + } + return offset; + } + return len; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/CellContext.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/CellContext.java new file mode 100644 index 00000000000..0e650cd7200 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/CellContext.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +import javafx.scene.Node; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Text cell accessor valid during the layout pass. + * This class enables extending RichTextArea by allowing for additional {@code StyleAttribute}s. + */ +public interface CellContext { + /** + * Adds a direct style. + *

+ * The direct style must be a valid CSS style string, for example {@code "-fx-font-size:15px;"}. + * This string might contain multiple CSS properties. + * + * @param fxStyle the direct style string + */ + public void addStyle(String fxStyle); + + /** + * Returns the node being styled. + *

+ * This might be a TextFlow (for the paragraph cell context) or Text (for the text segment cell context). + * @return the node being styled. + */ + public Node getNode(); + + /** + * Returns the current attributes. + * @return the current attributes. + */ + public StyleAttrs getAttributes(); +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/CodeArea.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/CodeArea.java new file mode 100644 index 00000000000..3748149c0a7 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/CodeArea.java @@ -0,0 +1,424 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package jfx.incubator.scene.control.rich; + +import java.util.List; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.WritableValue; +import javafx.css.CssMetaData; +import javafx.css.FontCssMetaData; +import javafx.css.StyleOrigin; +import javafx.css.Styleable; +import javafx.css.StyleableDoubleProperty; +import javafx.css.StyleableIntegerProperty; +import javafx.css.StyleableObjectProperty; +import javafx.css.StyleableProperty; +import javafx.css.converter.SizeConverter; +import javafx.scene.AccessibleAttribute; +import javafx.scene.Node; +import javafx.scene.control.Labeled; +import javafx.scene.text.Font; +import com.sun.jfx.incubator.scene.control.rich.Params; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.StyledTextModel; +import jfx.incubator.scene.control.rich.skin.CodeAreaSkin; +import jfx.incubator.scene.control.rich.skin.RichTextAreaSkin; + +/** + * CodeArea is a text component which supports styling (a.k.a. "syntax highlighting") of plain text. + *

+ * Unlike its base class {@link RichTextArea}, the {@code CodeArea} requires a special kind of model to be used, + * a {@link CodeTextModel}. + *

Creating a CodeArea

+ *

+ * The following example creates an editable control with the default {@link CodeArea}: + *

{@code    CodeArea codeArea = new CodeArea();
+ *   codeArea.setWrapText(true);
+ *   codeArea.setLineNumbersEnabled(true);
+ *   codeArea.setText("Lorem\nIpsum");
+ * }
+ * Which results in the following visual representation: + *

+ * Image of the CodeArea control + *

+ */ +public class CodeArea extends RichTextArea { + private BooleanProperty lineNumbers; + private StyleableIntegerProperty tabSize; + private StyleableObjectProperty font; + private StyleableDoubleProperty lineSpacing; + private String fontStyle; + + /** + * This constructor creates the CodeArea with the specified {@link CodeTextModel}. + * @param model the instance of {@link CodeTextModel} to use + */ + public CodeArea(CodeTextModel model) { + super(model); + + getStyleClass().add("code-area"); + + modelProperty().addListener((s, prev, newValue) -> { + // TODO is there a better way? + // perhaps even block any change of (already set CodeModel) + if (newValue != null) { + if (!(newValue instanceof CodeTextModel)) { + setModel(prev); + throw new IllegalArgumentException("model must be of type " + CodeTextModel.class); + } + } + }); + } + + /** + * This constructor creates the CodeArea with the default {@link CodeTextModel}. + */ + public CodeArea() { + this(new CodeTextModel()); + } + + @Override + protected RichTextAreaSkin createDefaultSkin() { + return new CodeAreaSkin(this); + } + + /** + * This convenience method sets the decorator property in the {@link CodeTextModel}. + * Nothing is done if the model is null. + * + * @param d the syntax decorator + * @see CodeTextModel#setDecorator(SyntaxDecorator) + */ + public final void setSyntaxDecorator(SyntaxDecorator d) { + CodeTextModel m = codeModel(); + if (m != null) { + m.setDecorator(d); + } + } + + /** + * This convenience method returns the syntax decorator value in the {@link CodeTextModel}, + * or null if the model is null. + * @return the syntax decorator value, or null + */ + public final SyntaxDecorator getSyntaxDecorator() { + CodeTextModel m = codeModel(); + return (m == null) ? null : m.getDecorator(); + } + + /** + * Determines whether to show line numbers. + * @return the line numbers enabled property + * @defaultValue false + */ + // TODO should there be a way to customize the line number component? createLineNumberDecorator() ? + // TODO should this be a styleable property? + public final BooleanProperty lineNumbersEnabledProperty() { + if (lineNumbers == null) { + lineNumbers = new SimpleBooleanProperty() { + @Override + protected void invalidated() { + LineNumberDecorator d; + if (get()) { + // TODO create line number decorator method? + d = new LineNumberDecorator() { + @Override + public Node getNode(int ix, boolean forMeasurement) { + Node n = super.getNode(ix, forMeasurement); + if (n instanceof Labeled t) { + t.fontProperty().bind(fontProperty()); + } + return n; + } + }; + } else { + d = null; + } + setLeftDecorator(d); + } + }; + } + return lineNumbers; + } + + public final boolean isLineNumbersEnabled() { + return lineNumbers == null ? false : lineNumbers.get(); + } + + public final void setLineNumbersEnabled(boolean on) { + lineNumbersEnabledProperty().set(on); + } + + /** + * The size of a tab stop in spaces. + * Values less than 1 are treated as 1. + * @return the tab size property + * @defaultValue 8 + */ + public final IntegerProperty tabSizeProperty() { + if (tabSize == null) { + tabSize = new StyleableIntegerProperty(Params.DEFAULT_TAB_SIZE) { + @Override + public Object getBean() { + return CodeArea.this; + } + + @Override + public String getName() { + return "tabSize"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.TAB_SIZE; + } + }; + } + return tabSize; + } + + public final int getTabSize() { + return tabSize == null ? Params.DEFAULT_TAB_SIZE : tabSize.get(); + } + + public final void setTabSize(int spaces) { + tabSizeProperty().set(spaces); + } + + /** + * The font to use for text in the {@code CodeArea}. + * @return the font property + * @defaultValue the Monospaced font with size 12.0 px + */ + public final ObjectProperty fontProperty() { + if (font == null) { + font = new StyleableObjectProperty(defaultFont()) + { + private boolean fontSetByCss; + + @Override + public void applyStyle(StyleOrigin newOrigin, Font value) { + // TODO perhaps this is not needed + // RT-20727 JDK-8127428 + // if CSS is setting the font, then make sure invalidate doesn't call NodeHelper.reapplyCSS + try { + // super.applyStyle calls set which might throw if value is bound. + // Have to make sure fontSetByCss is reset. + fontSetByCss = true; + super.applyStyle(newOrigin, value); + } catch (Exception e) { + throw e; + } finally { + fontSetByCss = false; + } + } + + @Override + public void set(Font value) { + Font old = get(); + if (value == null ? old == null : value.equals(old)) { + return; + } + super.set(value); + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.FONT; + } + + @Override + public Object getBean() { + return CodeArea.this; + } + + @Override + public String getName() { + return "font"; + } + }; + } + return font; + } + + public final void setFont(Font value) { + fontProperty().setValue(value); + } + + public final Font getFont() { + return font == null ? defaultFont() : font.getValue(); + } + + private static Font defaultFont() { + return Font.font("Monospaced", 12.0); + } + + /** + * Defines the vertical space in pixels between lines. + * + * @return the property instance + * @defaultValue 0 + */ + public final DoubleProperty lineSpacingProperty() { + if (lineSpacing == null) { + lineSpacing = new StyleableDoubleProperty(0) { + @Override + public Object getBean() { + return CodeArea.this; + } + + @Override + public String getName() { + return "lineSpacing"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.LINE_SPACING; + } + }; + } + return lineSpacing; + } + + public final void setLineSpacing(double spacing) { + lineSpacingProperty().set(spacing); + } + + public final double getLineSpacing() { + return lineSpacing == null ? 0 : lineSpacing.get(); + } + + @Override + public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { + switch (attribute) { + case FONT: + return getFont(); + default: + return super.queryAccessibleAttribute(attribute, parameters); + } + } + + /** styleable properties */ + private static class StyleableProperties { + private static final CssMetaData LINE_SPACING = + new CssMetaData<>("-fx-line-spacing", SizeConverter.getInstance(), 0) { + + @Override public boolean isSettable(CodeArea n) { + return n.lineSpacing == null || !n.lineSpacing.isBound(); + } + + @Override public StyleableProperty getStyleableProperty(CodeArea n) { + return (StyleableProperty)n.lineSpacingProperty(); + } + }; + + private static final FontCssMetaData FONT = + new FontCssMetaData<>("-fx-font", defaultFont()) + { + @Override + public boolean isSettable(CodeArea n) { + return n.font == null || !n.font.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(CodeArea n) { + return (StyleableProperty)(WritableValue)n.fontProperty(); + } + }; + + private static final CssMetaData TAB_SIZE = + new CssMetaData<>("-fx-tab-size", SizeConverter.getInstance(), Params.DEFAULT_TAB_SIZE) + { + @Override + public boolean isSettable(CodeArea n) { + return n.tabSize == null || !n.tabSize.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(CodeArea n) { + return (StyleableProperty)n.tabSizeProperty(); + } + }; + + private static final List> STYLEABLES = RichUtils.combine( + RichTextArea.getClassCssMetaData(), + FONT, + LINE_SPACING, + TAB_SIZE + ); + } + + /** + * Gets the {@code CssMetaData} associated with this class, which may include the + * {@code CssMetaData} of its superclasses. + * @return the {@code CssMetaData} + */ + public static List> getClassCssMetaData() { + return StyleableProperties.STYLEABLES; + } + + @Override + public List> getControlCssMetaData() { + return StyleableProperties.STYLEABLES; + } + + /** + * Returns plain text. + * @return plain text + */ + public final String getText() { + // TODO or use save(DataFormat, Writer) ? + StyledTextModel m = getModel(); + StringBuilder sb = new StringBuilder(4096); + int sz = m.size(); + for(int i=0; i + * The caret gets reset to the start of the document, selection gets cleared, and an undo event gets created. + * @param text the text string + */ + public final void setText(String text) { + TextPos end = getDocumentEnd(); + getModel().replace(null, TextPos.ZERO, end, text, true); + } + + private CodeTextModel codeModel() { + return (CodeTextModel)getModel(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/LineNumberDecorator.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/LineNumberDecorator.java new file mode 100644 index 00000000000..7570325e992 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/LineNumberDecorator.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +import java.text.DecimalFormat; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; + +/** + * Side decorator that shows model 1-based paragraph numbers. + */ +public class LineNumberDecorator implements SideDecorator { + private final DecimalFormat format; + + /** + * Creates an instance using Western-style group separator (comma). + */ + public LineNumberDecorator() { + this("#,##0"); + } + + /** + * Creates an instance using specified pattern for {@link DecimalFormat}. + * @param pattern DecimalFormat pattern to use + */ + public LineNumberDecorator(String pattern) { + format = new DecimalFormat(pattern); + } + + @Override + public double getPrefWidth(double viewWidth) { + // no set width, must request a measurer Node + return 0; + } + + @Override + public Node getNode(int ix, boolean forMeasurement) { + if (forMeasurement) { + // for measurer node only: allow for extra digit(s) in the bottom rows + ix += 300; + } + + String s = format.format(ix + 1); + if (forMeasurement) { + // account for some variability with proportional font + s += " "; + } + + Label t = new Label(s); + t.getStyleClass().add("line-number-decorator"); + // label needs to fill all available space + t.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + // do not interfere with vflow layout + t.setMinHeight(1); + t.setPrefHeight(1); + // numbers should be right aligned + t.setAlignment(Pos.CENTER_RIGHT); + t.setOpacity(1.0); + return t; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/Marker.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/Marker.java new file mode 100644 index 00000000000..1ee6fcc4154 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/Marker.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich; + +import java.util.Objects; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import com.sun.jfx.incubator.scene.control.rich.MarkerHelper; + +/** + * Tracks the text position in a document in the presence of edits. + */ +public final class Marker implements Comparable { + static { + MarkerHelper.setAccessor(new MarkerHelper.Accessor() { + @Override + public void setMarkerPos(Marker m, TextPos p) { + m.setTextPos(p); + } + + @Override + public Marker createMarker(TextPos p) { + return new Marker(p); + } + }); + } + + private final ReadOnlyObjectWrapper pos; + + private Marker(TextPos pos) { + Objects.nonNull(pos); + this.pos = new ReadOnlyObjectWrapper<>(pos); + } + + @Override + public String toString() { + return "Marker{" + getIndex() + "," + getOffset() + "}"; + } + + /** + * This property tracks the marker's position within the model (value is never null). + * @return the text position property + */ + public final ReadOnlyObjectProperty textPosProperty() { + return pos.getReadOnlyProperty(); + } + + public final TextPos getTextPos() { + return pos.get(); + } + + private final void setTextPos(TextPos p) { + pos.set(p); + } + + @Override + public final int compareTo(Marker m) { + return getTextPos().compareTo(m.getTextPos()); + } + + @Override + public int hashCode() { + int h = Marker.class.hashCode(); + h = h * 31 + getTextPos().hashCode(); + return h; + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof Marker m) { + return getTextPos().equals(m.getTextPos()); + } + return false; + } + + /** + * Returns the model paragraph index. + * @return paragraph index + */ + public final int getIndex() { + return getTextPos().index(); + } + + /** + * Returns the insert offset within the paragraph. + * @return offset value + */ + public final int getOffset() { + return getTextPos().offset(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/RichTextArea.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/RichTextArea.java new file mode 100644 index 00000000000..15be714bec9 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/RichTextArea.java @@ -0,0 +1,2260 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.css.CssMetaData; +import javafx.css.SimpleStyleableObjectProperty; +import javafx.css.StyleConverter; +import javafx.css.Styleable; +import javafx.css.StyleableBooleanProperty; +import javafx.css.StyleableProperty; +import javafx.css.converter.DurationConverter; +import javafx.css.converter.InsetsConverter; +import javafx.geometry.Insets; +import javafx.geometry.NodeOrientation; +import javafx.geometry.Point2D; +import javafx.scene.AccessibleAttribute; +import javafx.scene.AccessibleRole; +import javafx.scene.control.Control; +import javafx.scene.input.DataFormat; +import javafx.util.Duration; +import com.sun.jfx.incubator.scene.control.input.InputMapHelper; +import com.sun.jfx.incubator.scene.control.rich.CssStyles; +import com.sun.jfx.incubator.scene.control.rich.Params; +import com.sun.jfx.incubator.scene.control.rich.RichTextAreaSkinHelper; +import com.sun.jfx.incubator.scene.control.rich.VFlow; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.input.FunctionTag; +import jfx.incubator.scene.control.input.InputMap; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledTextModel; +import jfx.incubator.scene.control.rich.skin.RichTextAreaSkin; + +/** + * The RichTextArea control is designed for visualizing and editing rich text that can be styled in a variety of ways. + * + *

The RichTextArea control has a number of features, including: + *

    + *
  • {@link StyledTextModel paragraph-oriented model}, up to ~2 billion rows + *
  • virtualized text cell flow + *
  • support for text styling with an application stylesheet or {@link StyleAttrs inline attributes} + *
  • supports for multiple views connected to the same model + *
  • {@link SelectionModel single selection} + *
  • {@link InputMap input map} which allows for easy behavior customization and extension + *
+ * + *

Creating a RichTextArea

+ *

+ * The following example creates an editable control with the default {@link EditableRichTextModel}: + *

{@code    RichTextArea textArea = new RichTextArea();
+ * }
+ * The methods + * {@code appendText()}, {@code insertText()}, {@code replaceText()}, {@code applyStyle()}, + * {@code setStyle()}, or {@code clear()} can be used to modify text programmatically: + *
{@code    // create styles
+ *   StyleAttrs heading = StyleAttrs.builder().setBold(true).setUnderline(true).setFontSize(18).build();
+ *   StyleAttrs mono = StyleAttrs.builder().setFontFamily("Monospaced").build();
+ *
+ *   RichTextArea textArea = new RichTextArea();
+ *   // build the content
+ *   textArea.appendText("RichTextArea\n", heading);
+ *   textArea.appendText("Example:\nText is ", StyleAttrs.EMPTY);
+ *   textArea.appendText("monospaced.\n", mono);
+ * }
+ * Which results in the following visual representation: + *

+ * Image of the RichTextArea control + *

+ *

+ * A view-only information control requires a different model. The following example illustrates how to + * create a model that uses a style sheet for styling: + *

{@code
+ *     SimpleViewOnlyStyledModel m = new SimpleViewOnlyStyledModel();
+ *     // add text segment using CSS style name (requires a style sheet)
+ *     m.addSegment("RichTextArea ", null, "HEADER");
+ *     // add text segment using direct style
+ *     m.addSegment("Demo", "-fx-font-size:200%;", null);
+ *     // newline
+ *     m.nl();
+ *
+ *     RichTextArea textArea = new RichTextArea(m);
+ * }
+ * + *

Text Models

+ *

+ * A number of standard models can be used with RichTextArea, each addressing a specific use case: + *

+ * + * + * + * + * + * + * + * + * + *
Standard Models
Model ClassDescription
{@link StyledTextModel}
Base class (abstract)
 ├─ {@link EditableRichTextModel}
Default model for RichTextArea
 ├─ {@link jfx.incubator.scene.control.rich.model.PlainTextModel PlainTextModel}
Unstyled plain text model
 │   └─ {@link jfx.incubator.scene.control.rich.model.CodeTextModel CodeTextModel}
Default model for CodeArea
 └─ {@link jfx.incubator.scene.control.rich.model.StyledTextModelViewOnlyBase StyledTextModelViewOnlyBase}
Base class for a view-only model (abstract)
     └─ {@link jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel SimpleViewOnlyStyledModel}
In-memory view-only styled model
+ * + *

Selection

+ *

+ * The RichTextArea control maintains a single {@link #selectionProperty() contiguous selection segment} + * as a part of the {@link SelectionModel}. Additionally, + * {@link #anchorPositionProperty()} and {@link #caretPositionProperty()} read-only properties + * are derived from the {@link #selectionProperty()} for convenience. + * + *

Customizing

+ * The RichTextArea control offers some degree of customization that does not require subclassing: + *
    + *
  • customizing key bindings with the {@link InputMap} + *
  • setting {@link #leftDecoratorProperty() leftDecorator} + * and {@link #rightDecoratorProperty() rightDecorator} properties + *
+ * + * @since 999 TODO + */ +public class RichTextArea extends Control { + /** + * Function tags serve as identifiers of methods that can be customized via the {@code InputMap}. + *

+ * Any method in RichTextArea referenced by one of these tags can be customized by providing + * a different implementation using {@link InputMap#registerFunction(FunctionTag, FunctionHandler)}. + * Additionally, a key binding can be customized (added, removed, or replaced) via + * {@link InputMap#registerKey(jfx.incubator.scene.control.input.KeyBinding, FunctionTag)} or + * {@link InputMap#register(jfx.incubator.scene.control.input.KeyBinding, FunctionHandler)}. + */ + public static class Tags { + /** Deletes the symbol before the caret. */ + public static final FunctionTag BACKSPACE = new FunctionTag(); + /** Copies selected text to the clipboard. */ + public static final FunctionTag COPY = new FunctionTag(); + /** Cuts selected text and places it to the clipboard. */ + public static final FunctionTag CUT = new FunctionTag(); + /** Deletes symbol at the caret. */ + public static final FunctionTag DELETE = new FunctionTag(); + /** Deletes paragraph at the caret, or selected paragraphs */ + public static final FunctionTag DELETE_PARAGRAPH = new FunctionTag(); + /** Deletes text from the caret to paragraph start, ignoring selection. */ + public static final FunctionTag DELETE_PARAGRAPH_START = new FunctionTag(); + /** Deletes empty paragraph or text to the end of the next word. */ + public static final FunctionTag DELETE_WORD_NEXT_END = new FunctionTag(); + /** Deletes empty paragraph or text to the start of the next word. */ + public static final FunctionTag DELETE_WORD_NEXT_START = new FunctionTag(); + /** Deletes (multiple) empty paragraphs or text to the beginning of the previous word. */ + public static final FunctionTag DELETE_WORD_PREVIOUS = new FunctionTag(); + /** Clears any existing selection by moving anchor to the caret position */ + public static final FunctionTag DESELECT = new FunctionTag(); + /** Focus the next focusable node */ + public static final FunctionTag FOCUS_NEXT = new FunctionTag(); + /** Focus the previous focusable node */ + public static final FunctionTag FOCUS_PREVIOUS = new FunctionTag(); + /** Inserts a line break at the caret. */ + public static final FunctionTag INSERT_LINE_BREAK = new FunctionTag(); + /** Moves the caret one visual line down. */ + public static final FunctionTag MOVE_DOWN = new FunctionTag(); + /** Moves the caret one symbol to the left. */ + public static final FunctionTag MOVE_LEFT = new FunctionTag(); + /** Moves the caret to the end of the current paragraph, or, if already there, to the end of the next paragraph. */ + public static final FunctionTag MOVE_PARAGRAPH_DOWN = new FunctionTag(); + /** Moves the caret to the start of the current paragraph, or, if already there, to the start of the previous paragraph. */ + public static final FunctionTag MOVE_PARAGRAPH_UP = new FunctionTag(); + /** Moves the caret one symbol to the right. */ + public static final FunctionTag MOVE_RIGHT = new FunctionTag(); + /** Moves the caret to after the last character of the text. */ + public static final FunctionTag MOVE_TO_DOCUMENT_END = new FunctionTag(); + /** Moves the caret to before the first character of the text. */ + public static final FunctionTag MOVE_TO_DOCUMENT_START = new FunctionTag(); + /** Moves the caret to the end of the paragraph at caret. */ + public static final FunctionTag MOVE_TO_PARAGRAPH_END = new FunctionTag(); + /** Moves the caret to the beginning of the paragraph at caret. */ + public static final FunctionTag MOVE_TO_PARAGRAPH_START = new FunctionTag(); + /** Moves the caret one visual text line up. */ + public static final FunctionTag MOVE_UP = new FunctionTag(); + /** Moves the caret one word left (previous word if LTR, next word if RTL). */ + public static final FunctionTag MOVE_WORD_LEFT = new FunctionTag(); + /** Moves the caret to the start of the next word, or next paragraph if at the start of an empty paragraph. */ + public static final FunctionTag MOVE_WORD_NEXT_START = new FunctionTag(); + /** Moves the caret to the end of the next word. */ + public static final FunctionTag MOVE_WORD_NEXT_END = new FunctionTag(); + /** Moves the caret to the beginning of previous word. */ + public static final FunctionTag MOVE_WORD_PREVIOUS = new FunctionTag(); + /** Moves the caret one word right (next word if LTR, previous word if RTL). */ + public static final FunctionTag MOVE_WORD_RIGHT = new FunctionTag(); + /** Moves the caret one visual page down. */ + public static final FunctionTag PAGE_DOWN = new FunctionTag(); + /** Moves the caret one visual page up. */ + public static final FunctionTag PAGE_UP = new FunctionTag(); + /** Pastes the clipboard content. */ + public static final FunctionTag PASTE = new FunctionTag(); + /** Pastes the plain text clipboard content. */ + public static final FunctionTag PASTE_PLAIN_TEXT = new FunctionTag(); + /** If possible, redoes the last undone modification. */ + public static final FunctionTag REDO = new FunctionTag(); + /** Selects all text in the document. */ + public static final FunctionTag SELECT_ALL = new FunctionTag(); + /** Extends selection one visual text line down. */ + public static final FunctionTag SELECT_DOWN = new FunctionTag(); + /** Extends selection one symbol to the left. */ + public static final FunctionTag SELECT_LEFT = new FunctionTag(); + /** Extends selection one visible page down. */ + public static final FunctionTag SELECT_PAGE_DOWN = new FunctionTag(); + /** Extends selection one visible page up. */ + public static final FunctionTag SELECT_PAGE_UP = new FunctionTag(); + /** Selects the current paragraph. */ + public static final FunctionTag SELECT_PARAGRAPH = new FunctionTag(); + /** Extends selection to the end of the current paragraph, or, if already there, to the end of the next paragraph. */ + public static final FunctionTag SELECT_PARAGRAPH_DOWN = new FunctionTag(); + /** Extends selection to the paragraph end. */ + public static final FunctionTag SELECT_PARAGRAPH_END = new FunctionTag(); + /** Extends selection to the paragraph start. */ + public static final FunctionTag SELECT_PARAGRAPH_START = new FunctionTag(); + /** Extends selection to the start of the current paragraph, or, if already there, to the start of the previous paragraph. */ + public static final FunctionTag SELECT_PARAGRAPH_UP = new FunctionTag(); + /** Extends selection one symbol to the right. */ + public static final FunctionTag SELECT_RIGHT = new FunctionTag(); + /** Extends selection to the end of the document. */ + public static final FunctionTag SELECT_TO_DOCUMENT_END = new FunctionTag(); + /** Extends selection to the start of the document. */ + public static final FunctionTag SELECT_TO_DOCUMENT_START = new FunctionTag(); + /** Extends selection one visual text line up. */ + public static final FunctionTag SELECT_UP = new FunctionTag(); + /** Selects a word at the caret position. */ + public static final FunctionTag SELECT_WORD = new FunctionTag(); + /** Extends selection to the previous word (LTR) or next word (RTL). */ + public static final FunctionTag SELECT_WORD_LEFT = new FunctionTag(); + /** Extends selection to the beginning of next word. */ + public static final FunctionTag SELECT_WORD_NEXT = new FunctionTag(); + /** Extends selection to the end of next word. */ + public static final FunctionTag SELECT_WORD_NEXT_END = new FunctionTag(); + /** Extends selection to the previous word. */ + public static final FunctionTag SELECT_WORD_PREVIOUS = new FunctionTag(); + /** Extends selection to the next word (LTR) or previous word (RTL). */ + public static final FunctionTag SELECT_WORD_RIGHT = new FunctionTag(); + /** Inserts a tab symbol at the caret (editable), or transfer focus to the next focusable node. */ + public static final FunctionTag INSERT_TAB = new FunctionTag(); + /** If possible, undoes the last modification. */ + public static final FunctionTag UNDO = new FunctionTag(); + + private Tags() { } + } + + private SimpleObjectProperty model; + private final SelectionModel selectionModel = new SingleSelectionModel(); + private SimpleBooleanProperty editableProperty; + private SimpleObjectProperty leftDecorator; + private SimpleObjectProperty rightDecorator; + private ReadOnlyBooleanWrapper undoable; + private ReadOnlyBooleanWrapper redoable; + // styleables + private SimpleStyleableObjectProperty caretBlinkPeriod; + private SimpleStyleableObjectProperty contentPadding; + private StyleableBooleanProperty displayCaret; + private StyleableBooleanProperty highlightCurrentParagraph; + private StyleableBooleanProperty useContentHeight; + private StyleableBooleanProperty useContentWidth; + private StyleableBooleanProperty wrapText; + // will be moved to Control JDK-8314968 + private final InputMap inputMap = new InputMap(this); + + /** The style handler registry instance, made available for use by subclasses to add support for new style attributes. */ + protected static final StyleHandlerRegistry styleHandlerRegistry = initStyleHandlerRegistry(); + + /** + * Creates the instance with the in-memory model {@link EditableRichTextModel}. + */ + public RichTextArea() { + this(new EditableRichTextModel()); + } + + /** + * Creates the instance using the specified model. + *

+ * Multiple RichTextArea instances can work off a single model. + * + * @param model the model + */ + public RichTextArea(StyledTextModel model) { + setFocusTraversable(true); + getStyleClass().add("rich-text-area"); + setAccessibleRole(AccessibleRole.TEXT_AREA); + setModel(model); + } + + // Properties + + /** + * Tracks the selection anchor position within the document. This read-only property is derived from + * {@link #selectionProperty() selection} property. The value can be null. + *

+ * Setting a {@link SelectionSegment} causes an update to both the anchor and the caret positions. + * A null selection segment results in both positions to become null, a non-null selection segment sets both to + * non-null values. + *

+ * Note: + * {@code StyledTextModel.selectionProperty()}, {@link #anchorPositionProperty()}, and {@link #caretPositionProperty()} + * are logically connected. When a change occurs, the anchor position is updated first, followed by + * the caret position, followed by the selection segment. + * + * @return the anchor position property + * @see selectionProperty + * @see caretPositionProperty + * @defaultValue null + */ + public final ReadOnlyProperty anchorPositionProperty() { + return selectionModel.anchorPositionProperty(); + } + + public final TextPos getAnchorPosition() { + return anchorPositionProperty().getValue(); + } + + /** + * Determines the caret blink period. This property cannot be set to {@code null}. + * + * @return the caret blink period property + * @defaultValue 1000 ms + */ + public final ObjectProperty caretBlinkPeriodProperty() { + if (caretBlinkPeriod == null) { + caretBlinkPeriod = new SimpleStyleableObjectProperty<>( + StyleableProperties.CARET_BLINK_PERIOD, + this, + "caretBlinkPeriod", + Params.DEFAULT_CARET_BLINK_PERIOD + ) { + private Duration old; + + @Override + public void invalidated() { + final Duration v = get(); + if (v == null) { + set(old); + throw new NullPointerException("cannot set caretBlinkPeriodProperty to null"); + } + old = v; + } + }; + } + return caretBlinkPeriod; + } + + public final void setCaretBlinkPeriod(Duration period) { + caretBlinkPeriodProperty().set(period); + } + + public final Duration getCaretBlinkPeriod() { + return caretBlinkPeriod == null ? Params.DEFAULT_CARET_BLINK_PERIOD : caretBlinkPeriod.get(); + } + + /** + * Tracks the caret position within the document. This read-only property is derived from + * {@link #selectionProperty() selection} property. The value can be null. + *

+ * Setting a {@link SelectionSegment} causes an update to both the anchor and the caret positions. + * A null selection segment results in both positions to become null, a non-null selection segment sets both to + * non-null values. + *

+ * Note: + * {@code StyledTextModel.selectionProperty()}, {@link #anchorPositionProperty()}, and {@link #caretPositionProperty()} + * are logically connected. When a change occurs, the anchor position is updated first, followed by + * the caret position, followed by the selection segment. + * + * @return the caret position property + * @see selectionProperty + * @see anchorPositionProperty + * @defaultValue null + */ + public final ReadOnlyProperty caretPositionProperty() { + return selectionModel.caretPositionProperty(); + } + + public final TextPos getCaretPosition() { + return caretPositionProperty().getValue(); + } + + /** + * Specifies the padding for the RichTextArea content. + * The content padding value can be null, which is treated as no padding. + * + * @return the content padding property + * @defaultValue 4 pixels vertical padding, 8 pixels horizontal padding + */ + public final ObjectProperty contentPaddingProperty() { + if (contentPadding == null) { + contentPadding = new SimpleStyleableObjectProperty( + StyleableProperties.CONTENT_PADDING, + this, + "contentPadding", + Params.DEFAULT_CONTENT_PADDING + ); + } + return contentPadding; + } + + public final void setContentPadding(Insets value) { + contentPaddingProperty().set(value); + } + + public final Insets getContentPadding() { + return contentPadding == null ? Params.DEFAULT_CONTENT_PADDING : contentPadding.get(); + } + + /** + * This property controls whether caret will be displayed or not. + * + * @return the display caret property + * @defaultValue true + */ + public final BooleanProperty displayCaretProperty() { + if (displayCaret == null) { + displayCaret = new StyleableBooleanProperty(Params.DEFAULT_DISPLAY_CARET) { + @Override + public Object getBean() { + return RichTextArea.this; + } + + @Override + public String getName() { + return "displayCaret"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.DISPLAY_CARET; + } + }; + } + return displayCaret; + } + + public final void setDisplayCaret(boolean on) { + displayCaretProperty().set(on); + } + + public final boolean isDisplayCaret() { + return displayCaret == null ? Params.DEFAULT_DISPLAY_CARET : displayCaret.get(); + } + + /** + * Indicates whether this RichTextArea can be edited by the user, provided the model is also editable. + * Changing the value of this property with a view-only model or a null model has no effect. + * + * @return the editable property + * @see canEdit() method + * @defaultValue true + */ + public final BooleanProperty editableProperty() { + if (editableProperty == null) { + editableProperty = new SimpleBooleanProperty(this, "editable", true); + } + return editableProperty; + } + + public final boolean isEditable() { + if (editableProperty == null) { + return true; + } + return editableProperty().get(); + } + + public final void setEditable(boolean on) { + editableProperty().set(on); + } + + /** + * Indicates whether the current paragraph will be visually highlighted. + * + * @return the highlight current paragraph property + * @defaultValue false + */ + public final BooleanProperty highlightCurrentParagraphProperty() { + if (highlightCurrentParagraph == null) { + highlightCurrentParagraph = new StyleableBooleanProperty(Params.DEFAULT_HIGHLIGHT_CURRENT_PARAGRAPH) { + @Override + public Object getBean() { + return RichTextArea.this; + } + + @Override + public String getName() { + return "highlightCurrentParagraph"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.HIGHLIGHT_CURRENT_PARAGRAPH; + } + }; + } + return highlightCurrentParagraph; + } + + public final boolean isHighlightCurrentParagraph() { + return highlightCurrentParagraph == null ? Params.DEFAULT_HIGHLIGHT_CURRENT_PARAGRAPH : highlightCurrentParagraph.get(); + } + + public final void setHighlightCurrentParagraph(boolean on) { + highlightCurrentParagraphProperty().set(on); + } + + /** + * Specifies the left-side paragraph decorator. + * The value can be null. + * + * @return the left decorator property + * @defaultValue null + */ + public final ObjectProperty leftDecoratorProperty() { + if (leftDecorator == null) { + leftDecorator = new SimpleObjectProperty<>(this, "leftDecorator"); + } + return leftDecorator; + } + + public final SideDecorator getLeftDecorator() { + if (leftDecorator == null) { + return null; + } + return leftDecorator.get(); + } + + public final void setLeftDecorator(SideDecorator d) { + leftDecoratorProperty().set(d); + } + + /** + * Determines the {@link StyledTextModel} to use with this RichTextArea. + * The model can be null, which results in an empty, uneditable control. + *

+ * Note: Subclasses may impose additional restrictions on the type of the model they require. + * + * @return the model property + * @defaultValue an instance of {@link EditableRichTextModel} + */ + public final ObjectProperty modelProperty() { + if (model == null) { + model = new SimpleObjectProperty<>(this, "model") { + @Override + protected void invalidated() { + if (undoable != null) { + undoable.unbind(); + StyledTextModel m = get(); + if (m != null) { + undoable.bind(m.undoableProperty()); + } + } + + if(redoable != null) { + redoable.unbind(); + StyledTextModel m = get(); + if (m != null) { + redoable.bind(m.redoableProperty()); + } + } + + selectionModel.clear(); + } + }; + } + return model; + } + + public final void setModel(StyledTextModel m) { + modelProperty().set(m); + } + + public final StyledTextModel getModel() { + return model == null ? null : model.get(); + } + + /** + * The property describes if it's currently possible to redo the latest change of the content that was undone. + * + * @return the read-only property + * @defaultValue false + */ + public final ReadOnlyBooleanProperty redoableProperty() { + if (redoable == null) { + redoable = new ReadOnlyBooleanWrapper(this, "redoable", false); + StyledTextModel m = getModel(); + if (m != null) { + redoable.bind(m.redoableProperty()); + } + } + return redoable.getReadOnlyProperty(); + } + + public final boolean isRedoable() { + return redoableProperty().get(); + } + + /** + * Specifies the right-side paragraph decorator. + * The value can be null. + * + * @return the right decorator property + * @defaultValue null + */ + public final ObjectProperty rightDecoratorProperty() { + if (rightDecorator == null) { + rightDecorator = new SimpleObjectProperty<>(this, "rightDecorator"); + } + return rightDecorator; + } + + public final SideDecorator getRightDecorator() { + if (rightDecorator == null) { + return null; + } + return rightDecorator.get(); + } + + public final void setRightDecorator(SideDecorator d) { + rightDecoratorProperty().set(d); + } + + /** + * Tracks the current selection. The {@link SelectionSegment} consists of two values - the caret and the anchor + * positions which may get changed independently. This property allows for tracking the selection as an single + * entity. A null value for the selection segment causes both the caret and the anchor positions to become + * null. + *

+ * Note: + * {@code StyledTextModel.selectionProperty()}, {@link #anchorPositionProperty()}, and {@link #caretPositionProperty()} + * are logically connected. When a change occurs, the anchor position is updated first, followed by + * the caret position, followed by the selection segment. + * + * @return the selection property + * @see anchorPositionProperty + * @see caretPositionProperty + * @defaultValue null + */ + public final ReadOnlyProperty selectionProperty() { + return selectionModel.selectionProperty(); + } + + public final SelectionSegment getSelection() { + return selectionModel.getSelection(); + } + + /** + * The property describes if it's currently possible to undo the latest change of the content that was done. + * + * @return the read-only property + * @defaultValue false + */ + public final ReadOnlyBooleanProperty undoableProperty() { + if (undoable == null) { + undoable = new ReadOnlyBooleanWrapper(this, "undoable", false); + StyledTextModel m = getModel(); + if (m != null) { + undoable.bind(m.undoableProperty()); + } + } + return undoable.getReadOnlyProperty(); + } + + public final boolean isUndoable() { + return undoableProperty().get(); + } + + /** + * Determines whether the preferred height is the same as the content height. + * When set to true, the vertical scroll bar is disabled. + * + * @return the use content height property + * @defaultValue false + */ + public final BooleanProperty useContentHeightProperty() { + if (useContentHeight == null) { + useContentHeight = new StyleableBooleanProperty(Params.DEFAULT_USE_CONTENT_HEIGHT) { + @Override + public Object getBean() { + return RichTextArea.this; + } + + @Override + public String getName() { + return "useContentHeight"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.USE_CONTENT_HEIGHT; + } + }; + } + return useContentHeight; + } + + public final boolean isUseContentHeight() { + return useContentHeight == null ? Params.DEFAULT_USE_CONTENT_HEIGHT : useContentHeight.get(); + } + + public final void setUseContentHeight(boolean on) { + useContentHeightProperty().set(true); + } + + /** + * Determines whether the preferred width is the same as the content width. + * When set to true, the horizontal scroll bar is disabled. + * + * @return the use content width property + * @defaultValue false + */ + public final BooleanProperty useContentWidthProperty() { + if (useContentWidth == null) { + useContentWidth = new StyleableBooleanProperty(Params.DEFAULT_USE_CONTENT_WIDTH) { + @Override + public Object getBean() { + return RichTextArea.this; + } + + @Override + public String getName() { + return "useContentWidth"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.USE_CONTENT_WIDTH; + } + }; + } + return useContentWidth; + } + + public final boolean isUseContentWidth() { + return useContentWidth == null ? Params.DEFAULT_USE_CONTENT_WIDTH : useContentWidth.get(); + } + + public final void setUseContentWidth(boolean on) { + useContentWidthProperty().set(true); + } + + /** + * Indicates whether text should be wrapped in this RichTextArea. + * If a run of text exceeds the width of the {@code RichTextArea}, + * then this variable indicates whether the text should wrap onto + * another line. + * Setting this property to {@code true} hides the horizontal scroll bar. + * + * @return the wrap text property + * @defaultValue false + */ + public final BooleanProperty wrapTextProperty() { + if (wrapText == null) { + wrapText = new StyleableBooleanProperty(Params.DEFAULT_WRAP_TEXT) { + @Override + public Object getBean() { + return RichTextArea.this; + } + + @Override + public String getName() { + return "wrapText"; + } + + @Override + public CssMetaData getCssMetaData() { + return StyleableProperties.WRAP_TEXT; + } + }; + } + return wrapText; + } + + public final boolean isWrapText() { + return wrapText == null ? Params.DEFAULT_WRAP_TEXT : wrapText.getValue(); + } + + public final void setWrapText(boolean value) { + wrapTextProperty().setValue(value); + } + + // Styleable Properties + + /** Defines styleable properties at the class level */ + private static class StyleableProperties { + private static final CssMetaData CARET_BLINK_PERIOD = + new CssMetaData<>("-fx-caret-blink-period", DurationConverter.getInstance()) + { + @Override + public boolean isSettable(RichTextArea t) { + return t.caretBlinkPeriod == null || !t.caretBlinkPeriod.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RichTextArea t) { + return (StyleableProperty)t.caretBlinkPeriodProperty(); + } + }; + + private static final CssMetaData CONTENT_PADDING = + new CssMetaData<>("-fx-content-padding", InsetsConverter.getInstance(), Params.DEFAULT_CONTENT_PADDING) + { + @Override + public boolean isSettable(RichTextArea t) { + return t.contentPadding == null || !t.contentPadding.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RichTextArea t) { + return (StyleableProperty)t.contentPaddingProperty(); + } + }; + + private static final CssMetaData DISPLAY_CARET = + new CssMetaData<>("-fx-display-caret", StyleConverter.getBooleanConverter(), Params.DEFAULT_DISPLAY_CARET) + { + @Override + public boolean isSettable(RichTextArea t) { + return t.displayCaret == null || !t.displayCaret.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RichTextArea t) { + return (StyleableProperty)t.displayCaretProperty(); + } + }; + + private static final CssMetaData HIGHLIGHT_CURRENT_PARAGRAPH = + new CssMetaData<>("-fx-highlight-current-paragraph", StyleConverter.getBooleanConverter(), Params.DEFAULT_HIGHLIGHT_CURRENT_PARAGRAPH) + { + @Override + public boolean isSettable(RichTextArea t) { + return t.highlightCurrentParagraph == null || !t.highlightCurrentParagraph.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RichTextArea t) { + return (StyleableProperty)t.highlightCurrentParagraphProperty(); + } + }; + + private static final CssMetaData USE_CONTENT_HEIGHT = + new CssMetaData<>("-fx-use-content-height", StyleConverter.getBooleanConverter(), Params.DEFAULT_USE_CONTENT_HEIGHT) + { + @Override + public boolean isSettable(RichTextArea t) { + return t.useContentHeight == null || !t.useContentHeight.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RichTextArea t) { + return (StyleableProperty)t.useContentHeightProperty(); + } + }; + + private static final CssMetaData USE_CONTENT_WIDTH = + new CssMetaData<>("-fx-use-content-width", StyleConverter.getBooleanConverter(), Params.DEFAULT_USE_CONTENT_WIDTH) + { + @Override + public boolean isSettable(RichTextArea t) { + return t.useContentWidth == null || !t.useContentWidth.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RichTextArea t) { + return (StyleableProperty)t.useContentWidthProperty(); + } + }; + + private static final CssMetaData WRAP_TEXT = + new CssMetaData<>("-fx-wrap-text", StyleConverter.getBooleanConverter(), Params.DEFAULT_WRAP_TEXT) + { + @Override + public boolean isSettable(RichTextArea t) { + return t.wrapText == null || !t.wrapText.isBound(); + } + + @Override + public StyleableProperty getStyleableProperty(RichTextArea t) { + return (StyleableProperty)t.wrapTextProperty(); + } + }; + + private static final List> STYLEABLES = RichUtils.combine( + Control.getClassCssMetaData(), + CARET_BLINK_PERIOD, + CONTENT_PADDING, + DISPLAY_CARET, + HIGHLIGHT_CURRENT_PARAGRAPH, + USE_CONTENT_HEIGHT, + USE_CONTENT_WIDTH, + WRAP_TEXT + ); + } + + /** + * Gets the {@code CssMetaData} associated with this class, which may include the + * {@code CssMetaData} of its superclasses. + * @return the {@code CssMetaData} + */ + public static List> getClassCssMetaData() { + return StyleableProperties.STYLEABLES; + } + + @Override + public List> getControlCssMetaData() { + return getClassCssMetaData(); + } + + // Public Methods + + /** + * Appends the styled text to the end of the document. Any embedded {@code "\n"} or {@code "\r\n"} + * sequences result in a new paragraph being added. + *

+ * This method is no-op if either the control or the model is not editable. It is up to the model + * to select whether to accept all, some, or none of the + * {@link jfx.incubator.scene.control.rich.model.StyleAttribute StyleAttribute}s. + * + * @param text the text to append + * @param attrs the style attributes + * @return the text position at the end of the appended text, or null if editing is disabled + */ + public final TextPos appendText(String text, StyleAttrs attrs) { + TextPos p = getDocumentEnd(); + return insertText(p, text, attrs); + } + + /** + * Appends the styled content to the end of the document. Any embedded {@code "\n"} or {@code "\r\n"} + * sequences result in a new paragraph being added. + * This method is no-op if either the control or the model is not editable. + * + * @param in the input stream + * @return the text position at the end of the appended text, or null if editing is disabled + */ + public final TextPos appendText(StyledInput in) { + TextPos p = getDocumentEnd(); + return insertText(p, in); + } + + /** + * Applies the specified style to the selected range. The specified attributes will be merged, overriding + * the existing ones. + * When applying paragraph attributes, the affected range might extend beyond {@code start} and {@code end} + * to include whole paragraphs. + * + * @param start the start of text range + * @param end the end of text range + * @param attrs the style attributes to apply + */ + public void applyStyle(TextPos start, TextPos end, StyleAttrs attrs) { + if (canEdit()) { + StyledTextModel m = getModel(); + m.applyStyle(start, end, attrs, true); + } + } + + /** + * When selection exists, deletes selected text. Otherwise, deletes the character preceding the caret, + * possibly breaking up the grapheme clusters. + * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * + * @see RichTextArea.Tags#BACKSPACE + */ + public void backspace() { + execute(Tags.BACKSPACE); + } + + /** + * This convenience method returns true if all the following conditions are true: + *

    + *
  • this control's {@link #isEditable()} returns true
  • + *
  • the model is not {@code null}
  • + *
  • the model's {@link StyledTextModel#isUserEditable()} returns true
  • + *
+ * + * @return true if the editing is allowed + */ + public final boolean canEdit() { + if (isEditable()) { + StyledTextModel m = getModel(); + if (m != null) { + return m.isUserEditable(); + } + } + return false; + } + + /** + * Clears the document, creating an undo entry. + * This method does nothing if {@link #canEdit()} returns false. + */ + public final void clear() { + TextPos end = getDocumentEnd(); + replaceText(TextPos.ZERO, end, StyledInput.EMPTY, true); + } + + /** + * Clears existing selection, if any. This method is an alias for {@code getSelectionModel().clear()}. + */ + public final void clearSelection() { + selectionModel.clear(); + } + + /** + * Clears the undo-redo stack of the underlying model. + * This method does nothing if the model is null. + */ + public final void clearUndoRedo() { + StyledTextModel m = getModel(); + if (m != null) { + m.clearUndoRedo(); + } + } + + /** + * When selection exists, copies the selected rich text to the clipboard in all the formats supported + * by the model. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#COPY + */ + public void copy() { + execute(Tags.COPY); + } + + /** + * Copies the selected text in the specified format to the clipboard. + * This method does nothing if no selection exists or when the data format is not supported by the model. + * + * @param format the data format to use + */ + public final void copy(DataFormat format) { + RichTextAreaSkin skin = richTextAreaSkin(); + if (skin != null) { + skin.copyText(format); + } + } + + /** + * Transfers the currently selected text to the clipboard, + * removing the current selection. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#CUT + */ + public void cut() { + execute(Tags.CUT); + } + + /** + * When selection exists, deletes selected text. Otherwise, deletes the symbol at the caret. + * When the symbol at the caret is a grapheme cluster, deletes the whole cluster. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#DELETE + */ + public void delete() { + execute(Tags.DELETE); + } + + /** + * When selection exists, deletes selected paragraphs. Otherwise, deletes the paragraph at the caret. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#DELETE_PARAGRAPH + */ + public void deleteParagraph() { + execute(Tags.DELETE_PARAGRAPH); + } + + /** + * Deletes text from the caret position to the start of the paragraph, ignoring existing selection. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#DELETE_PARAGRAPH_START + */ + public void deleteParagraphStart() { + execute(Tags.DELETE_PARAGRAPH_START); + } + + /** + * Deletes from the caret positon to the end of next word, ignoring existing selection. + * When the caret is in an empty paragraph, deletes the paragraph. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#DELETE_WORD_NEXT_END + */ + public void deleteWordNextEnd() { + execute(Tags.DELETE_WORD_NEXT_END); + } + + /** + * Deletes from the caret positon to the start of next word, ignoring existing selection. + * When the caret is in an empty paragraph, deletes the paragraph. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#DELETE_WORD_NEXT_START + */ + public void deleteWordNextStart() { + execute(Tags.DELETE_WORD_NEXT_START); + } + + /** + * Deletes (multiple) empty paragraphs or text from the caret position to the start of the previous word, + * ignoring existing selection. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#DELETE_WORD_PREVIOUS + */ + public void deleteWordPrevious() { + execute(Tags.DELETE_WORD_PREVIOUS); + } + + /** + * Clears the selected text range by moving anchor to the caret position. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#DESELECT + */ + public void deselect() { + execute(Tags.DESELECT); + } + + /** + * Executes a function mapped to the specified function tag. + * This method does nothing if no function is mapped to the tag, or the function has been unbound. + * + * @param tag the function tag + */ + // TODO to be moved to Control JDK-8314968 + public final void execute(FunctionTag tag) { + InputMapHelper.execute(this, getInputMap(), tag); + } + + /** + * Executes the default function mapped to the specified tag. + * This method does nothing if no default mapping exists. + * + * @param tag the function tag + */ + // TODO to be moved to Control JDK-8314968 + public final void executeDefault(FunctionTag tag) { + InputMapHelper.executeDefault(this, getInputMap(), tag); + } + + /** + * Extends selection to the specified position. Internally, this method will normalized the position + * to be within the document boundaries. + * Calling this method produces the same result as {@code select(pos, pos)} if no prior selection exists. + * This method does nothing if the model is null. + * + * @param pos the text position + */ + public final void extendSelection(TextPos pos) { + StyledTextModel m = getModel(); + if (m != null) { + selectionModel.extendSelection(m, pos); + } + } + + /** + * Returns {@code StyleAttrs} which contains character and paragraph attributes. + *

+ * When selection exists, returns the attributes at the first selected character. + *

+ * When no selection exists, returns the attributes at the character which immediately precedes the caret. + * When at the beginning of the document, returns the attributes of the first character. + * If the model uses CSS styles, this method resolves individual attributes (bold, font size, etc.) + * for this instance of {@code RichTextArea}. + * + * @return the non-null {@code StyleAttrs} instance + */ + public final StyleAttrs getActiveStyleAttrs() { + StyleResolver r = resolver(); + return getModelStyleAttrs(r); + } + + /** + * Returns a TextPos corresponding to the end of the document. + * When the model is null, returns {@link TextPos#ZERO}. + * + * @return the text position + */ + public final TextPos getDocumentEnd() { + StyledTextModel m = getModel(); + return (m == null) ? TextPos.ZERO : m.getDocumentEnd(); + } + + /** + * Returns the input map instance. + * @return the input map instance + */ + // to be moved to Control JDK-8314968 + public final InputMap getInputMap() { + return inputMap; + } + + /** + * Returns the number of paragraphs in the model. This method returns 0 if the model is {@code null}. + * @return the paragraph count + */ + public final int getParagraphCount() { + StyledTextModel m = getModel(); + return (m == null) ? 0 : m.size(); + } + + /** + * Returns a TextPos corresponding to the end of paragraph. + * When the model is null, returns {@link TextPos#ZERO}. + * + * @param index paragraph index + * @return text position + */ + public final TextPos getParagraphEnd(int index) { + StyledTextModel m = getModel(); + return (m == null) ? TextPos.ZERO : m.getEndOfParagraphTextPos(index); + } + + /** + * Returns the plain text at the specified paragraph index. The value of {@code index} must be between + * 0 (inclusive) and the value returned by {@link #getParagraphCount()} (exclusive). + * + * @param index the paragraph index + * @return the non-null plain text string + * @throws IndexOutOfBoundsException if the index is outside of the range supported by the model + */ + public final String getPlainText(int index) { + if ((index < 0) || (index >= getParagraphCount())) { + throw new IndexOutOfBoundsException("index=" + index); + } + return getModel().getPlainText(index); + } + + /** + * Returns the style handler registry for this control. + * Applications should not normally call this method as it is intended for use by the skin + * subclasses. + * + * @return the style handler registry + */ + public StyleHandlerRegistry getStyleHandlerRegistry() { + return styleHandlerRegistry; + } + + /** + * Finds a text position corresponding to the specified screen coordinates. + * This method returns {@code null} if the specified coordinates are outside of the content area. + * + * @param screenX the screen x coordinate + * @param screenY the screen y coordinate + * @return the text position, or null + */ + public final TextPos getTextPosition(double screenX, double screenY) { + Point2D local = vflow().getContentPane().screenToLocal(screenX, screenY); + return vflow().getTextPosLocal(local.getX(), local.getY()); + } + + /** + * This convenience method returns true when a non-empty selection exists. + * + * @return true when an non-empty selection exists + */ + public final boolean hasNonEmptySelection() { + TextPos ca = getCaretPosition(); + if (ca != null) { + TextPos an = getAnchorPosition(); + if (an != null) { + return !ca.isSameInsertionIndex(an); + } + } + return false; + } + + /** + * Inserts a line break at the caret. If selection exists, first deletes the selected text. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#INSERT_LINE_BREAK + */ + public void insertLineBreak() { + execute(Tags.INSERT_LINE_BREAK); + } + + /** + * Inserts a tab symbol at the caret. If selection exists, first deletes the selected text. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#INSERT_TAB + */ + public void insertTab() { + execute(Tags.INSERT_TAB); + } + + /** + * Inserts the styled text at the specified position. Any embedded {@code "\n"} or {@code "\r\n"} + * sequences result in a new paragraph being added. + *

+ * This method does nothing if {@link #canEdit()} returns false. + * + * @param pos the insert position + * @param text the text to inser + * @param attrs the style attributes + * @return the text position at the end of the appended text, or null if editing is disabled + */ + public final TextPos insertText(TextPos pos, String text, StyleAttrs attrs) { + StyledInput in = StyledInput.of(text, attrs); + return replaceText(pos, pos, in, true); + } + + /** + * Inserts the styled content at the specified position. + *

+ * This method does nothing if {@link #canEdit()} returns false. + * + * @param pos the insert position + * @param in the input stream + * @return the text position at the end of the appended text, or null if editing is disabled + */ + public final TextPos insertText(TextPos pos, StyledInput in) { + return replaceText(pos, pos, in, true); + } + + /** + * Moves the caret to after the last character of the text, clearing an existing selection. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_TO_DOCUMENT_END + */ + public void moveDocumentEnd() { + execute(Tags.MOVE_TO_DOCUMENT_END); + } + + /** + * Moves the caret to before the first character of the text, clearing an existing selection. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_TO_DOCUMENT_START + */ + public void moveDocumentStart() { + execute(Tags.MOVE_TO_DOCUMENT_START); + } + + /** + * Moves the caret one visual line down, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_DOWN + */ + public void moveDown() { + execute(Tags.MOVE_DOWN); + } + + /** + * Moves the caret left, clearing an existing selection range. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_LEFT + */ + public void moveLeft() { + execute(Tags.MOVE_LEFT); + } + + /** + * Moves the caret to the end of the current paragraph, or, if already there, to the end of the next paragraph. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_PARAGRAPH_DOWN + */ + public void moveParagraphDown() { + execute(Tags.MOVE_PARAGRAPH_DOWN); + } + + /** + * Moves the caret to the end of the paragraph at caret, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_TO_PARAGRAPH_END + */ + public void moveParagraphEnd() { + execute(Tags.MOVE_TO_PARAGRAPH_END); + } + + /** + * Moves the caret to the start of the current paragraph, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_TO_PARAGRAPH_START + */ + public void moveParagraphStart() { + execute(Tags.MOVE_TO_PARAGRAPH_START); + } + + /** + * Moves the caret to the start of the current paragraph, or, if already there, to the start of the previous paragraph. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_PARAGRAPH_UP + */ + public void moveParagraphUp() { + execute(Tags.MOVE_PARAGRAPH_UP); + } + + /** + * Moves the caret to the next symbol, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_RIGHT + */ + public void moveRight() { + execute(Tags.MOVE_RIGHT); + } + + /** + * Moves the caret one visual line up, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_UP + */ + public void moveUp() { + execute(Tags.MOVE_UP); + } + + /** + * Moves the caret to the beginning of previous word in a left-to-right setting + * (or the beginning of the next word in a right-to-left setting), clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_WORD_LEFT + */ + public void moveWordLeft() { + execute(Tags.MOVE_WORD_LEFT); + } + + /** + * Moves the caret to the end of the next word, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_WORD_NEXT_END + */ + public void moveWordNextEnd() { + execute(Tags.MOVE_WORD_NEXT_END); + } + + /** + * Moves the caret to the start of next word, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_WORD_NEXT_START + */ + public void moveWordNextStart() { + execute(Tags.MOVE_WORD_NEXT_START); + } + + /** + * Moves the caret to the beginning of previous word, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_WORD_PREVIOUS + */ + public void moveWordPrevious() { + execute(Tags.MOVE_WORD_PREVIOUS); + } + + /** + * Moves the caret to the beginning of next word in a left-to-right setting + * (or the beginning of the previous word in a right-to-left setting), clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#MOVE_WORD_RIGHT + */ + public void moveWordRight() { + execute(Tags.MOVE_WORD_RIGHT); + } + + /** + * Move caret one visual page down, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#PAGE_DOWN + */ + public void pageDown() { + execute(Tags.PAGE_DOWN); + } + + /** + * Move caret one visual page up, clearing an existing selection. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#PAGE_UP + */ + public void pageUp() { + execute(Tags.PAGE_UP); + } + + /** + * Pastes the clipboard content at the caret, or, if selection exists, replacing the selected text. + * This method clears the selection afterward. + * It is up to the model to pick the best data format to paste. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#PASTE + */ + public void paste() { + execute(Tags.PASTE); + } + + /** + * Pastes the clipboard content at the caret, or, if selection exists, replacing the selected text. + *

+ * This method does nothing if {@link #canEdit()} returns false, of if the specified format is + * not supported by the model. + * + * @param format the data format to use + */ + public void paste(DataFormat format) { + RichTextAreaSkin skin = richTextAreaSkin(); + if (skin != null) { + skin.pasteText(format); + } + } + + /** + * Pastes the plain text clipboard content at the caret, or, if selection exists, replacing the selected text. + *

+ * This method does nothing if {@link #canEdit()} returns false, or the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#PASTE_PLAIN_TEXT + */ + public final void pastePlainText() { + execute(Tags.PASTE_PLAIN_TEXT); + } + + /** + * Calls the model to replace the current document with the content read from the stream using + * the specified {@code DataFormat}. + * Any existing content is discarded and undo/redo buffer is cleared. + *

+ * This method does not close the input stream. This method does nothing if the model is {@code null}. + * + * @param f the data format + * @param in the input stream + * @throws IOException if an I/O error occurs + * @throws UnsupportedOperationException when the data format is not supported by the model + */ + public final void read(DataFormat f, InputStream in) throws IOException { + StyledTextModel m = getModel(); + if (m != null) { + StyleResolver r = resolver(); + m.read(r, f, in); + select(TextPos.ZERO, TextPos.ZERO); + } + } + + /** + * Calls the model to replace the current document with the content read from the input stream. + * The model picks the best {@code DataFormat} to use based on priority. + * Any existing content is discarded and undo/redo buffer is cleared. + *

+ * This method does not close the input stream. This method does nothing if the model is {@code null}. + * + * @param in the input stream + * @throws IOException if an I/O error occurs + * @throws UnsupportedOperationException when the data format is not supported by the model + */ + public final void read(InputStream in) throws IOException { + DataFormat f = bestDataFormat(false); + if (f != null) { + read(f, in); + } + } + + /** + * If possible, redoes the last undone modification. If {@link #isRedoable()} returns + * false, then calling this method has no effect. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#REDO + */ + public void redo() { + execute(Tags.REDO); + } + + /** + * Replaces the specified range with the new text. + * + * @param start the start text position + * @param end the end text position + * @param text the input text + * @param allowUndo when true, creates an undo-redo entry + * @return the new caret position at the end of inserted text, or null if the change cannot be made + */ + public final TextPos replaceText(TextPos start, TextPos end, String text, boolean allowUndo) { + if (canEdit()) { + StyledTextModel m = getModel(); + return m.replace(vflow(), start, end, text, allowUndo); + } + return null; + } + + /** + * Replaces the specified range with the new input. + *

+ * This method does nothing if the model is null. + * + * @param start the start text position + * @param end the end text position + * @param in the input stream + * @param createUndo when true, creates an undo-redo entry + * @return the new caret position at the end of inserted text, or null if the change cannot be made + */ + public final TextPos replaceText(TextPos start, TextPos end, StyledInput in, boolean createUndo) { + StyledTextModel m = getModel(); + if (m != null) { + return m.replace(vflow(), start, end, in, createUndo); + } + return null; + } + + /** + * Moves both the caret and the anchor to the specified position, clearing any existing selection. + * This method is equivalent to {@code select(pos, pos)}. + * + * @param pos the text position + */ + public final void select(TextPos pos) { + select(pos, pos); + } + + /** + * Selects the specified range and places the caret at the new position. + * Both positions will be internally clamped to be within the document boundaries. + * This method does nothing if the model is null. + * + * @param anchor the new selection anchor position + * @param caret the new caret position + */ + public final void select(TextPos anchor, TextPos caret) { + StyledTextModel m = getModel(); + if (m != null) { + selectionModel.setSelection(m, anchor, caret); + } + } + + /** + * Selects all the text in the document: the anchor is set at the document start, while the caret is positioned + * at the end of the document. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_ALL + */ + public void selectAll() { + execute(Tags.SELECT_ALL); + } + + /** + * Extends selection one visual text line down. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_DOWN + */ + public void selectDown() { + execute(Tags.SELECT_DOWN); + } + + /** + * Extends selection one symbol to the left. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_LEFT + */ + public void selectLeft() { + execute(Tags.SELECT_LEFT); + } + + /** + * Extends selection one visible page down. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_PAGE_DOWN + */ + public void selectPageDown() { + execute(Tags.SELECT_PAGE_DOWN); + } + + /** + * Extends selection one visible page up. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_PAGE_UP + */ + public void selectPageUp() { + execute(Tags.SELECT_PAGE_UP); + } + + /** + * Selects the current paragraph. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_PARAGRAPH + */ + public void selectParagraph() { + execute(Tags.SELECT_PARAGRAPH); + } + + /** + * Extends selection to the end of the current paragraph, or, if already at the end, + * to the end of the next paragraph. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_PARAGRAPH_DOWN + */ + public void selectParagraphDown() { + execute(Tags.SELECT_PARAGRAPH_DOWN); + } + + /** + * Selects from the current position to the paragraph end. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_PARAGRAPH_END + */ + public void selectParagraphEnd() { + execute(Tags.SELECT_PARAGRAPH_END); + } + + /** + * Selects from the current position to the paragraph start. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_PARAGRAPH_START + */ + public void selectParagraphStart() { + execute(Tags.SELECT_PARAGRAPH_START); + } + + /** + * Extends selection to the start of the current paragraph, or, if already at the start, + * to the start of the previous paragraph. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_PARAGRAPH_UP + */ + public void selectParagraphUp() { + execute(Tags.SELECT_PARAGRAPH_UP); + } + + /** + * Extends selection one symbol (or grapheme cluster) to the right. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_RIGHT + */ + public void selectRight() { + execute(Tags.SELECT_RIGHT); + } + + /** + * Extends selection to the end of the document. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_TO_DOCUMENT_END + */ + public void selectToDocumentEnd() { + execute(Tags.SELECT_TO_DOCUMENT_END); + } + + /** + * Extends selection to the start of the document. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_TO_DOCUMENT_START + */ + public void selectToDocumentStart() { + execute(Tags.SELECT_TO_DOCUMENT_START); + } + + /** + * Extends selection one visual text line up. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_UP + */ + public void selectUp() { + execute(Tags.SELECT_UP); + } + + /** + * Selects a word at the caret position. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_WORD + */ + public void selectWord() { + execute(Tags.SELECT_WORD); + } + + /** + * Moves the caret to the beginning of previous word in a left-to-right setting, + * or to the beginning of the next word in a right-to-left setting. + * This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of next word. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_WORD_LEFT + */ + public void selectWordLeft() { + execute(Tags.SELECT_WORD_LEFT); + } + + /** + * Extends selection to the end of the next word. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_WORD_NEXT_END + */ + public void selectWordNextEnd() { + execute(Tags.SELECT_WORD_NEXT_END); + } + + /** + * Moves the caret to the start of the next word. This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of next word. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_WORD_NEXT + */ + public void selectWordNextStart() { + execute(Tags.SELECT_WORD_NEXT); + } + + /** + * Moves the caret to the beginning of previous word. This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of previous word. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_WORD_PREVIOUS + */ + public void selectWordPrevious() { + execute(Tags.SELECT_WORD_PREVIOUS); + } + + /** + * Moves the caret to the beginning of next word in a left-to-right setting, + * or to the beginning of the previous word in a right-to-left setting. + * This does not cause + * the selection to be cleared. Rather, the anchor stays put and the caretPosition is + * moved to the beginning of next word. + *

+ * This method does nothing when the caret position is {@code null}. + *

+ * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#SELECT_WORD_RIGHT + */ + public void selectWordRight() { + execute(Tags.SELECT_WORD_RIGHT); + } + + /** + * Sets the specified style to the selected range. + * All the existing attributes in the selected range will be cleared. + * When setting the paragraph attributes, the affected range + * might be wider than one specified. + *

+ * This method does nothing if {@link #canEdit()} returns false. + * + * @param start the start of text range + * @param end the end of text range + * @param attrs the style attributes to set + */ + public final void setStyle(TextPos start, TextPos end, StyleAttrs attrs) { + if (canEdit()) { + StyledTextModel m = getModel(); + m.applyStyle(start, end, attrs, false); + } + } + + /** + * If possible, undoes the last modification. If {@link #isUndoable()} returns + * false, then calling this method has no effect. + *

+ * This method does nothing if {@link #canEdit()} returns false. + * + * This action can be changed by remapping the default behavior via {@link InputMap}. + * @see RichTextArea.Tags#UNDO + */ + public void undo() { + execute(Tags.UNDO); + } + + /** + * Calls the model to writes the current document to the output stream using the specified {@code DataFormat}. + *

+ * This method does not close the output stream. This method does nothing if the model is {@code null}. + * + * @param f the data format + * @param out the output stream + * @throws IOException if an I/O error occurs + * @throws UnsupportedOperationException when the data format is not supported by the model + */ + public final void write(DataFormat f, OutputStream out) throws IOException { + StyledTextModel m = getModel(); + if (m != null) { + StyleResolver r = resolver(); + m.write(r, f, out); + } + } + + /** + * Calls the model to write the current document to the output stream, using the highest priority {@code DataFormat} + * as determined by the model. + *

+ * This method does not close the output stream. This method does nothing if the model is {@code null}. + * @param out the output stream + * @throws IOException if an I/O error occurs + * @throws UnsupportedOperationException when no suitable data format can be found + */ + public final void write(OutputStream out) throws IOException { + if (getModel() != null) { + DataFormat f = bestDataFormat(true); + if (f == null) { + throw new UnsupportedOperationException("no suitable format can be found"); + } + write(f, out); + } + } + + // Non-public Methods + + @Override + protected RichTextAreaSkin createDefaultSkin() { + return new RichTextAreaSkin(this); + } + + // package protected for testing + VFlow vflow() { + return RichTextAreaSkinHelper.getVFlow(this); + } + + private RichTextAreaSkin richTextAreaSkin() { + return (RichTextAreaSkin)getSkin(); + } + + private StyleResolver resolver() { + RichTextAreaSkin skin = richTextAreaSkin(); + if (skin != null) { + return skin.getStyleResolver(); + } + return null; + } + + private StyleAttrs getModelStyleAttrs(StyleResolver r) { + StyledTextModel m = getModel(); + if (m != null) { + TextPos pos = getCaretPosition(); + if (pos != null) { + if (hasNonEmptySelection()) { + TextPos an = getAnchorPosition(); + if (pos.compareTo(an) > 0) { + pos = an; + } + } else if (!TextPos.ZERO.equals(pos)) { + int ix = pos.offset() - 1; + if (ix < 0) { + // FIX find previous symbol + ix = 0; + } + pos = new TextPos(pos.index(), ix); + } + return m.getStyleAttrs(r, pos); + } + } + return StyleAttrs.EMPTY; + } + + private static StyleHandlerRegistry initStyleHandlerRegistry() { + StyleHandlerRegistry.Builder b = StyleHandlerRegistry.builder(null); + + b.setParHandler(StyleAttrs.BACKGROUND, (c, cx, v) -> { + String color = RichUtils.toCssColor(v); + cx.addStyle("-fx-background-color:" + color + ";"); + }); + + b.setSegHandler(StyleAttrs.BOLD, (c, cx, v) -> { + cx.addStyle(v ? "-fx-font-weight:bold;" : "-fx-font-weight:normal;"); + }); + + b.setSegHandler(CssStyles.CSS, (c, cx, v) -> { + String st = v.style(); + if (st != null) { + cx.addStyle(st); + } + String[] names = v.names(); + if (names != null) { + cx.getNode().getStyleClass().addAll(names); + } + }); + + b.setSegHandler(StyleAttrs.FONT_FAMILY, (c, cx, v) -> { + cx.addStyle("-fx-font-family:'" + v + "';"); + }); + + b.setSegHandler(StyleAttrs.FONT_SIZE, (c, cx, v) -> { + cx.addStyle("-fx-font-size:" + v + ";"); + }); + + b.setSegHandler(StyleAttrs.ITALIC, (c, cx, v) -> { + if (v) { + cx.addStyle("-fx-font-style:italic;"); + } + }); + + b.setParHandler(StyleAttrs.LINE_SPACING, (c, cx, v) -> { + cx.addStyle("-fx-line-spacing:" + v + ";"); + }); + + b.setParHandler(StyleAttrs.PARAGRAPH_DIRECTION, (ctrl, cx, v) -> { + if (ctrl.isWrapText()) { + // node orientation property is not styleable (yet?) + switch (v) { + case LEFT_TO_RIGHT: + cx.getNode().setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); + break; + case RIGHT_TO_LEFT: + cx.getNode().setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); + break; + } + } + }); + + // this is a special case: 4 attributes merged into one -fx style + // unfortunately, this might create multiple copies of the same style string + StyleAttributeHandler spaceHandler = (c, cx, v) -> { + StyleAttrs a = cx.getAttributes(); + double top = a.getDouble(StyleAttrs.SPACE_ABOVE, 0); + double right = a.getDouble(StyleAttrs.SPACE_RIGHT, 0); + double bottom = a.getDouble(StyleAttrs.SPACE_BELOW, 0); + double left = a.getDouble(StyleAttrs.SPACE_LEFT, 0); + cx.addStyle("-fx-padding:" + top + ' ' + right + ' ' + bottom + ' ' + left + ";"); + }; + b.setParHandler(StyleAttrs.SPACE_ABOVE, spaceHandler); + b.setParHandler(StyleAttrs.SPACE_RIGHT, spaceHandler); + b.setParHandler(StyleAttrs.SPACE_BELOW, spaceHandler); + b.setParHandler(StyleAttrs.SPACE_LEFT, spaceHandler); + + b.setSegHandler(StyleAttrs.STRIKE_THROUGH, (c, cx, v) -> { + if (v) { + cx.addStyle("-fx-strikethrough:true;"); + } + }); + + b.setParHandler(StyleAttrs.TEXT_ALIGNMENT, (c, cx, v) -> { + if (c.isWrapText()) { + String alignment = RichUtils.toCss(v); + cx.addStyle("-fx-text-alignment:" + alignment + ";"); + } + }); + + b.setSegHandler(StyleAttrs.TEXT_COLOR, (c, cx, v) -> { + String color = RichUtils.toCssColor(v); + cx.addStyle("-fx-fill:" + color + ";"); + }); + + b.setSegHandler(StyleAttrs.UNDERLINE, (cc, cx, v) -> { + if (v) { + cx.addStyle("-fx-underline:true;"); + } + }); + + return b.build(); + } + + private DataFormat bestDataFormat(boolean forExport) { + StyledTextModel m = getModel(); + if (m != null) { + DataFormat[] fs = m.getSupportedDataFormats(forExport); + if (fs.length > 0) { + return fs[0]; + } + } + return null; + } + + @Override + public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { + Object rv = queryAccessibleAttribute2(attribute, parameters); + // FIX System.out.println(attribute + ":" + rv); + return rv; + } + private Object queryAccessibleAttribute2(AccessibleAttribute attribute, Object... parameters) { + switch (attribute) { +// case BOUNDS_FOR_RANGE: +// { +// int start = (Integer)parameters[0]; +// int end = (Integer)parameters[1]; +// PathElement[] elements = rangeShape(start, end + 1); +// /* Each bounds is defined by a MoveTo (top-left) followed by +// * 4 LineTo (to top-right, bottom-right, bottom-left, back to top-left). +// */ +// Bounds[] bounds = new Bounds[elements.length / 5]; +// int index = 0; +// for (int i = 0; i < bounds.length; i++) { +// MoveTo topLeft = (MoveTo)elements[index]; +// LineTo topRight = (LineTo)elements[index+1]; +// LineTo bottomRight = (LineTo)elements[index+2]; +// BoundingBox b = new BoundingBox(topLeft.getX(), topLeft.getY(), +// topRight.getX() - topLeft.getX(), +// bottomRight.getY() - topRight.getY()); +// bounds[i] = localToScreen(b); +// index += 5; +// } +// return bounds; +// } + case EDITABLE: + return isEditable(); + case TEXT: + String accText = getAccessibleText(); + if (accText != null && !accText.isEmpty()) { + return accText; + } + // unlike TextArea, we cannot report the whole text as it might be too large. + // there are two choices here: + // either report the visible text, or the current paragraph text + TextPos p = getCaretPosition(); + return p == null ? null : getPlainText(p.index()); + +// case SELECTION_START: +// return getSelection().getStart(); +// case SELECTION_END: +// return getSelection().getEnd(); +// case CARET_OFFSET: +// return getCaretPosition(); + default: + return super.queryAccessibleAttribute(attribute, parameters); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SelectionModel.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SelectionModel.java new file mode 100644 index 00000000000..b8d60e4b92f --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SelectionModel.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich; + +import javafx.beans.property.ReadOnlyProperty; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * A Selection model that maintains a single {@link SelectionSegment}. + */ +// TODO perhaps we should support, at least theoretically, the concept of multiple selection +// and multiple carets. The impacted areas: +// this interface +// changes in VFlow to handle multiple carets and decorations +// changes in RichTextAreaBehavior to handle selection and keyboard navigation +public interface SelectionModel { + /** + * Clears the selection. This sets {@code selectionProperty}, + * {@code anchorPositionProperty}, and {@code caretPositionProperty} to null. + */ + public void clear(); + + /** + * Replaced existing selection, if any, with the new one. While this method will accept the text positions + * outside of the document range, the actual values of + * {@code anchorPositionProperty}, and {@code caretPositionProperty} must always remain within + * the valid range for the document. + * + * @param model the model, must be non-null + * @param anchor the anchor position, must be non-null + * @param caret the caret position, must be non-null + */ + public void setSelection(StyledTextModel model, TextPos anchor, TextPos caret); + + /** + * Extends selection to the specified position. + * Internally, the position will be normalized to be within the document boundaries. + * This method will issue a {@code setSelection(model, pos, pos)} + * call if the model instance is different from that passed before. + *

+ * While this method will accept the text position + * outside of the document range, the actual values of + * {@code anchorPositionProperty}, and {@code caretPositionProperty} must always remain within + * the valid range for the document. + * + * @param model the model, must be non-null + * @param pos the new caret position, must be non-null + */ + public void extendSelection(StyledTextModel model, TextPos pos); + + /** + * Caret position property. The value can be null, indicating no selection. When the caret position + * is {@code null}, the {@code selectionProperty} and the {@code anchorPositionProperty} are also {@code null}. + *

+ * Note: + * {@link #selectionProperty()}, {@link #anchorPositionProperty()}, and {@link #caretPositionProperty()} + * are logically connected. When a change occurs, the anchor position is updated first, followed by + * the caret position, followed by the selection segment. + * + * @return the caret position property + * @defaultValue null + */ + public ReadOnlyProperty caretPositionProperty(); + + /** + * Anchor position property. The value can be null, indicating no selection. When the anchor position + * is {@code null}, the {@code selectionProperty} and the {@code caretPositionProperty} are also {@code null}. + *

+ * Note: + * {@link #selectionProperty()}, {@link #anchorPositionProperty()}, and {@link #caretPositionProperty()} + * are logically connected. When a change occurs, the anchor position is updated first, followed by + * the caret position, followed by the selection segment. + * + * @return the anchor position property + * @defaultValue null + */ + public ReadOnlyProperty anchorPositionProperty(); + + /** + * Selection property. The value can be null, indicating no selection. When the selection segment + * is {@code null}, the {@code anchorPositionProperty} and the {@code caretPositionProperty} are also {@code null}. + * + * @return the selection property + * @defaultValue null + */ + public ReadOnlyProperty selectionProperty(); + + /** + * Returns the current selection. The value can be null, indicating no selection. When the selection segment + * is {@code null}, the {@code anchorPositionProperty} and the {@code caretPositionProperty} are also {@code null}. + * + * @return current selection, or null + */ + public SelectionSegment getSelection(); +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SelectionSegment.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SelectionSegment.java new file mode 100644 index 00000000000..d2d69356938 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SelectionSegment.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich; + +import java.util.Objects; + +/** + * Text selection segment, comprised of the selection anchor and the caret positions. + * The main purpose of this class is to enable tracking of selection changes as a single entity. + */ +public final class SelectionSegment { + private final TextPos min; + private final TextPos max; + private final boolean caretAtMin; + + /** + * Constructs the selection segment. + * + * @param anchor the anchor position, must not be null + * @param caret the caret position, must not be null + */ + public SelectionSegment(TextPos anchor, TextPos caret) { + Objects.requireNonNull(anchor, "anchor cannot be null"); + Objects.requireNonNull(caret, "caret cannot be null"); + + if (anchor.compareTo(caret) <= 0) { + this.min = anchor; + this.max = caret; + this.caretAtMin = false; + } else { + this.min = caret; + this.max = anchor; + this.caretAtMin = true; + } + } + + /** + * Returns the selection anchor position. + * @return the anchor position + */ + public final TextPos getAnchor() { + return caretAtMin ? max : min; + } + + /** + * Returns the caret position. + * @return the caret position + */ + public final TextPos getCaret() { + return caretAtMin ? min : max; + } + + /** + * Returns the position which is closer to the start of the document. + * @return the text position + */ + public final TextPos getMin() { + return min; + } + + /** + * Returns the position which is closer to the end of the document. + * @return the text position + */ + public TextPos getMax() { + return max; + } + + /** + * Returns true if the anchor and the caret are at the same position. + * @return true if the anchor and the caret are at the same position + */ + public boolean isCollapsed() { + return min.equals(max); + } + + @Override + public String toString() { + return "SelectionSegment{" + min + ", " + max + ", caretAtMin=" + caretAtMin + "}"; + } + + private static > void isLessThanOrEqual(T min, T max, String nameMin, String nameMax) { + if (min.compareTo(max) > 0) { + throw new IllegalArgumentException(nameMin + " must be less or equal to " + nameMax); + } + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof SelectionSegment s) { + return + (caretAtMin == s.caretAtMin) && + (min.equals(s.min)) && + (max.equals(s.max)); + } + return false; + } + + @Override + public int hashCode() { + int h = SelectionSegment.class.hashCode(); + h = 31 * h + Boolean.hashCode(caretAtMin); + h = 31 * h + min.hashCode(); + h = 31 * h + max.hashCode(); + return h; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SideDecorator.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SideDecorator.java new file mode 100644 index 00000000000..60185221031 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SideDecorator.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +import javafx.scene.Node; + +/** + * Provides a way to add side decorations to each paragraph + * in a {@link RichTextArea}. + *

+ * The side decorations Nodes are added to either left or right side of each paragraph. Each side node will be + * resized to the height of the corresponding paragraph. The width of all the side nodes, in order to avoid complicated + * layout process, would be determined by the following process: + *

    + *
  • if {@link #getPrefWidth} method returns a value greater than 0, that will be the width of all the side nodes. + *
  • otherwise, the {@link #getNode(int, boolean)} method is called with {@code forMeasurement} argument set to + * {@code true}. The value returned will be used to size all other nodes for that side. + *
+ */ +public interface SideDecorator { + /** + * Returns the width for all the side Nodes, or 0 if a measurer Node needs to be obtained via + * {@link #getNode(int, boolean)}. + * @param viewWidth width of the view + * @return preferred width + */ + public double getPrefWidth(double viewWidth); + + /** + * Creates a Node to be added to the layout to the right or to the left of the given paragraph. + *

+ * When {@code forMeasurement} is true, this method is expected to create a special non-null + * measurement Node, whose preferred width will be used to size all the side Nodes (and must, therefore, + * be wider than any side node in the view). The {@code modelIndex} is this case is the index of + * the first paragraph in the view. + *

+ * The measurement node will not be displayed and will be discarded. + * + * @param modelIndex model index + * @param forMeasurement when true, specifies that a measurement Node must be created + * @return new instance of the Node, or null + */ + public Node getNode(int modelIndex, boolean forMeasurement); +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SingleSelectionModel.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SingleSelectionModel.java new file mode 100644 index 00000000000..a2e0024c821 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SingleSelectionModel.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich; + +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.ReadOnlyProperty; +import javafx.beans.value.ChangeListener; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * This {@link SelectionModel} supports a single selection segment. + */ +public final class SingleSelectionModel implements SelectionModel { + private final ReadOnlyObjectWrapper segment = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyObjectWrapper anchorPosition = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyObjectWrapper caretPosition = new ReadOnlyObjectWrapper<>(); + private final ChangeListener anchorListener; + private final ChangeListener caretListener; + private Marker anchorMarker; + private Marker caretMarker; + private StyledTextModel model; + + /** The constructor. */ + public SingleSelectionModel() { + anchorListener = (src, old, pos) -> { + anchorPosition.set(pos); + segment.set(new SelectionSegment(pos, caretPosition.get())); + }; + caretListener = (src, old, pos) -> { + caretPosition.set(pos); + segment.set(new SelectionSegment(anchorPosition.get(), pos)); + }; + } + + @Override + public void clear() { + setSelectionSegment(null, null); + } + + @Override + public void setSelection(StyledTextModel model, TextPos anchor, TextPos caret) { + // non-null values are enforced by clamp() + anchor = model.clamp(anchor); + caret = model.clamp(caret); + SelectionSegment sel = new SelectionSegment(anchor, caret); + setSelectionSegment(model, sel); + } + + @Override + public void extendSelection(StyledTextModel model, TextPos pos) { + // reset selection if model is different + if (isFlippingModel(model)) { + setSelection(model, pos, pos); + return; + } + + pos = model.clamp(pos); + SelectionSegment sel = getSelection(); + TextPos a = sel == null ? null : sel.getAnchor(); + if (a == null) { + a = pos; + } else { + if(pos.compareTo(sel.getMin()) < 0) { + // extend before + a = sel.getMax(); + } else if(pos.compareTo(sel.getMax()) > 0) { + // extend after + a = sel.getMin(); + } else { + // extend from anchor to pos + a = sel.getAnchor(); + } + } + setSelection(model, a, pos); + } + + private boolean isFlippingModel(StyledTextModel m) { + if (model == null) { + return false; + } else if (m == null) { + return false; + } + return m != model; + } + + @Override + public ReadOnlyProperty anchorPositionProperty() { + return anchorPosition.getReadOnlyProperty(); + } + + @Override + public ReadOnlyProperty caretPositionProperty() { + return caretPosition; + } + + private void setSelectionSegment(StyledTextModel model, SelectionSegment sel) { + this.model = model; + + if (anchorMarker != null) { + anchorMarker.textPosProperty().removeListener(anchorListener); + anchorMarker = null; + } + + if (caretMarker != null) { + caretMarker.textPosProperty().removeListener(caretListener); + caretMarker = null; + } + + // since caretPosition, anchorPosition, and selectionSegment are separate properties, + // there is a possibility that one is null and another is not (for example, in a listener). + // this code guarantees a specific order of updates: + // 1. anchor + // 2. caret + // 3. selection segment + if (sel == null) { + anchorPosition.set(null); + caretPosition.set(null); + } else { + TextPos p = sel.getAnchor(); + anchorMarker = model.getMarker(p); + anchorPosition.set(p); + anchorMarker.textPosProperty().addListener(anchorListener); + + p = sel.getCaret(); + caretMarker = model.getMarker(p); + caretPosition.set(p); + caretMarker.textPosProperty().addListener(caretListener); + } + + segment.set(sel); + } + + @Override + public ReadOnlyProperty selectionProperty() { + return segment.getReadOnlyProperty(); + } + + @Override + public SelectionSegment getSelection() { + return segment.get(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleAttributeHandler.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleAttributeHandler.java new file mode 100644 index 00000000000..4d888d13dc3 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleAttributeHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +/** + * This functional interface defines a style attribute handler. + *

+ * This interface is needed when extending the RichTextArea with support for other style attributes. + * Applications should not normally use this interface. + *

+ * The purpose of this handler is to apply changes to the {@code CellContext} based on the value + * of the corresponding attribute. + * + * @param the actual type of RichTextArea control + * @param the attribute value type + */ +@FunctionalInterface +public interface StyleAttributeHandler { + /** + * Executes the attribute handler for the given control, cell context, + * and the attribute value. + * + * @param control the control + * @param cx the cell context + * @param value the attribute value + */ + public void apply(C control, CellContext cx, T value); +} \ No newline at end of file diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleHandlerRegistry.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleHandlerRegistry.java new file mode 100644 index 00000000000..6f50ec7c2e0 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleHandlerRegistry.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +import java.util.HashMap; +import jfx.incubator.scene.control.rich.model.StyleAttribute; + +/** + * Style Handler Registry keeps track of the {@code StyleAttributeHandler} for supported + * {@code StyleAttribute}s. The registry, once created using its {@code Builder}, is immutable. + * + * This class is needed when extending the RichTextArea with support for other style attributes. + * Applications should not normally use this interface. + */ +public class StyleHandlerRegistry { + private final HashMap parStyleHandlerMap; + private final HashMap segStyleHandlerMap; + + private StyleHandlerRegistry(HashMap p, HashMap s) { + parStyleHandlerMap = p; + segStyleHandlerMap = s; + } + + /** + * Creates a builder initialized with the parent's registry content. + * + * @param parent the parent class' registry (can be null) + * @return the builder instance + */ + public static Builder builder(StyleHandlerRegistry parent) { + Builder b = new Builder(); + if (parent != null) { + b.parStyleHandlerMap.putAll(parent.parStyleHandlerMap); + b.segStyleHandlerMap.putAll(parent.segStyleHandlerMap); + } + return b; + } + + /** + * Invokes the handler, if present, for the specified attribute in the context of the specified control. + * + * @param the control type + * @param the attribute value type + * @param control the control reference + * @param forParagraph specifies which attribute to search for: paragraph ({@code true}) or text segment ({@code false}) + * @param cx the cell context + * @param a the attribute + * @param value the attribute value + */ + public void process(C control, boolean forParagraph, CellContext cx, StyleAttribute a, T value) { + StyleAttributeHandler h = (forParagraph ? parStyleHandlerMap : segStyleHandlerMap).get(a); + if (h != null) { + h.apply(control, cx, value); + } + } + + /** + * The builder used to create an immutable instance of {@code StyleHandlerRegistry}. + */ + public static class Builder { + private HashMap parStyleHandlerMap = new HashMap<>(); + private HashMap segStyleHandlerMap = new HashMap<>(); + + Builder() { + } + + /** + * Sets the paragraph handler for the given attribute. + * + * @param the control type + * @param the attribute value type + * @param a the attribute + * @param h the handler + */ + public void setParHandler(StyleAttribute a, StyleAttributeHandler h) { + parStyleHandlerMap.put(a, h); + } + + /** + * Sets the text segment handler for the given attribute. + * + * @param the control type + * @param the attribute value type + * @param a the attribute + * @param h the handler + */ + public void setSegHandler(StyleAttribute a, StyleAttributeHandler h) { + segStyleHandlerMap.put(a, h); + } + + /** + * Creates an immutable instance of {@code StyleHandlerRegistry}. + * @return the {@code StyleHandlerRegistry} instance + */ + public StyleHandlerRegistry build() { + return new StyleHandlerRegistry(parStyleHandlerMap, segStyleHandlerMap); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleResolver.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleResolver.java new file mode 100644 index 00000000000..74a1e1c779e --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/StyleResolver.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +import javafx.scene.Node; +import javafx.scene.image.WritableImage; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Enables conversion of CSS styles to {@code StyleAttribute}s. + *

+ * Whenever the {@code StyledTextModel} contains logical class names instead of actual attributes, + * a separate CSS style resolution step is required. The resulting attributes might depend on the view that + * originated an operation such as exporting or coping. + *

+ * This interface is a part of API layer between the model and the view, and only comes to play when the + * model refers to CSS styles. + * Applications should not normally use this interface. + */ +public interface StyleResolver { + /** + * Resolves CSS styles (when present) to the individual attributes declared in {@link StyleAttrs}. + * + * @param attrs the style attributes + * @return the resolved style attributes + */ + public StyleAttrs resolveStyles(StyleAttrs attrs); + + /** + * Creates a snapshot of the specified Node to be exported or copied as an image. + * + * @param node the {@link Node} to make a snapshot of + * @return snapshot the generated {@link WritableImage} + */ + public WritableImage snapshot(Node node); +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SyntaxDecorator.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SyntaxDecorator.java new file mode 100644 index 00000000000..9316a28a3f5 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/SyntaxDecorator.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.RichParagraph; + +/** + * Decorates plain text by producing a {@link RichParagraph}. + */ +public interface SyntaxDecorator { + /** + * Creates a {@link RichParagraph} from the paragraph plain text. + * The text string is guaranteed to contain neither newline nor carriage return symbols. + * + * @param model the model + * @param index the paragraph index + * @return the decorated {@link RichParagraph} instance + */ + public RichParagraph createRichParagraph(CodeTextModel model, int index); + + /** + * Receives the updates from the model, before any of the model's + * {@link jfx.incubator.scene.control.rich.model.StyledTextModel.Listener StyledTextModel.Listener}s + * are notified. + *

+ * The implementation might do nothing if the syntax can be determined based on the text of a single + * paragraph. Other implementations, which handle more complex syntax might want to re-build the syntax model + * any time the plain text document changes, should use this method to trigger the refresh. + * + * @param m the model + * @param start start of the affected range + * @param end end of the affected range + * @param charsTop number of characters added before any added paragraphs + * @param linesAdded number of paragraphs inserted + * @param charsBottom number of characters added after any inserted paragraphs + */ + public void handleChange(CodeTextModel m, TextPos start, TextPos end, int charsTop, int linesAdded, int charsBottom); +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/TextPos.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/TextPos.java new file mode 100644 index 00000000000..575761b105d --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/TextPos.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +/** + * An immutable position within the text. + *

+ * Because it is immutable, it cannot track locations in the document which is being edited. + * For that, use {@link Marker}. + */ +public final class TextPos implements Comparable { + /** A text position at the start of the document. */ + // TODO should it be called START? + public static final TextPos ZERO = new TextPos(0, 0, 0, true); + private final int index; + private final int offset; + private final int charIndex; + private final boolean leading; + + /** + * Creates a new text position. + * @param index the paragraph index + * @param offset the text offset + * @param charIndex the character index + * @param leading true if leading + */ + public TextPos(int index, int offset, int charIndex, boolean leading) { + if (index < 0) { + throw new IllegalArgumentException("index cannot be negative"); + } else if (offset < 0) { + throw new IllegalArgumentException("offset cannot be negative"); + } else if (charIndex < 0) { + throw new IllegalArgumentException("charIndex cannot be negative"); + } + this.index = index; + this.offset = offset; + this.charIndex = charIndex; + this.leading = leading; + } + + /** + * Constructs a new text position. + * @param index the paragraph index + * @param offset the text offset + */ + public TextPos(int index, int offset) { + this(index, offset, offset, true); + } + + /** + * Returns the model paragraph index. + * @return the paragraph index + */ + public int index() { + return index; + } + + /** + * Returns the offset into the plain text string (insertion index). + * @return the offset + */ + public int offset() { + return offset; + } + + /** + * Returns the character index. + * @return the character index + */ + public int charIndex() { + return charIndex; + } + + /** + * Determines whether the text position is leading or trailing. + * @return true if leading text position + */ + public boolean isLeading() { + return leading; + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof TextPos p) { + return + (index == p.index) && + (charIndex == p.charIndex) && + (offset == p.offset) && + (leading == p.leading); + } + return false; + } + + @Override + public int hashCode() { + int h = TextPos.class.hashCode(); + h = 31 * h + index; + h = 31 * h + charIndex; + h = 31 * h + offset; + h = 31 * h + (leading ? 1 : 0); + return h; + } + + @Override + public int compareTo(TextPos p) { + int d = index - p.index; + if (d == 0) { + int off = offset(); + int poff = p.offset(); + if (off < poff) { + return -1; + } else if (off > poff) { + return 1; + } + if (leading != p.leading) { + return leading ? 1 : -1; + } + return 0; + } + return d; + } + + @Override + public String toString() { + return + "TextPos{" + + "ix=" + index + + ", off=" + offset + + ", cix=" + charIndex + + (leading ? ", leading" : ", trailing") + + "}"; + } + + /** + * Returns true if the specified insertion point is the same. + * @param p text position + * @return true if same insertion index + */ + public boolean isSameInsertionIndex(TextPos p) { + // added this method in case we need to add leading/trailing flag + // semantics of this test is the insertion points are the same. + if (p != null) { + if (index == p.index) { + return (offset == p.offset); + } + } + return false; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/CodeTextModel.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/CodeTextModel.java new file mode 100644 index 00000000000..8c93e958890 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/CodeTextModel.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.util.Set; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import jfx.incubator.scene.control.rich.SyntaxDecorator; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Editable plain text model with optional syntax highlighting for use with the + * {@link jfx.incubator.scene.control.rich.CodeArea CodeArea} control. + *

+ * This model supports custom content storage mechanism via {@link PlainTextModel.Content}. By default, + * the model provides an in-memory storage via its {@link PlainTextModel.InMemoryContent} implementation. + */ +public class CodeTextModel extends PlainTextModel { + private SimpleObjectProperty decorator; + private static final Set> SUPPORTED = initSupportedAttributes(); + + /** + * Constructs the CodeTextModel with an in-memory content. + */ + public CodeTextModel() { + } + + /** + * Constructs the CodeTextModel with the specified content. + * @param c the content + */ + public CodeTextModel(PlainTextModel.Content c) { + super(c); + } + + // only a subset of attributes are supported + private static Set> initSupportedAttributes() { + return Set.of( + StyleAttrs.BOLD, + StyleAttrs.ITALIC, + StyleAttrs.STRIKE_THROUGH, + StyleAttrs.TEXT_COLOR, + StyleAttrs.UNDERLINE + ); + } + + @Override + protected Set> getSupportedAttributes() { + return SUPPORTED; + } + + @Override + public final RichParagraph getParagraph(int index) { + SyntaxDecorator d = getDecorator(); + if (d == null) { + return super.getParagraph(index); + } else { + return d.createRichParagraph(this, index); + } + } + + /** + * Syntax decorator applies styling to the plain text stored in the model. + * @return the syntax decorator value (may be null) + */ + public final ObjectProperty decoratorProperty() { + if (decorator == null) { + decorator = new SimpleObjectProperty<>() { + @Override + protected void invalidated() { + SyntaxDecorator d = get(); + if (d != null) { + TextPos end = getDocumentEnd(); + d.handleChange(CodeTextModel.this, TextPos.ZERO, end, 0, 0, 0); + } + fireStylingUpdate(); + } + }; + } + return decorator; + } + + public final SyntaxDecorator getDecorator() { + return decorator == null ? null : decorator.get(); + } + + public final void setDecorator(SyntaxDecorator d) { + decoratorProperty().set(d); + } + + @Override + protected void fireChangeEvent(TextPos start, TextPos end, int charsTop, int linesAdded, int charsBottom) { + SyntaxDecorator d = getDecorator(); + if (d != null) { + d.handleChange(this, start, end, charsTop, linesAdded, charsBottom); + } + super.fireChangeEvent(start, end, charsTop, linesAdded, charsBottom); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ContentChange.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ContentChange.java new file mode 100644 index 00000000000..b85dcd81b4c --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ContentChange.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Contains information about {@link StyledTextModel} content change. + *

+ * This class represents two kinds of changes made to the model: one that modifies the textual content + * and embedded Nodes, and one that only changes styling of the content. + *

+ * A content change can be though of as a replacement of content between the two positions + * {@code start} and {@code end} with a different styled content (or no content). + * The change object does not include the actual content, but only the information about inserted symbols: + *

    + *
  • the number of characters inserted to the first affected paragraph + *
  • the number of complete paragraphs inserted + *
  • the number of characters inserted into the first paragraph following the inserted lines + *
+ * The following diagram illustrates the inserted content relative to the start position (S) and + * the end position (E): + *
+ * .......STTTTTTT
+ * AAAAAAAAAAAAAAA
+ * AAAAAAAAAAAAAAA
+ * BBBE...........
+ * 
+ * Where T are the characters inserted following the start position, A are the inserted complete paragraphs, + * and B are the characters inserted at the beginning of the paragraph that contains the end position. + * + * @since 999 TODO + */ +public abstract class ContentChange { + private final TextPos start; + private final TextPos end; + + private ContentChange(TextPos start, TextPos end) { + this.start = start; + this.end = end; + } + + /** + * Returns the start position. + * @return the start position + */ + public TextPos getStart() { + return start; + } + + /** + * Returns the end position. + * @return the end position + */ + public TextPos getEnd() { + return end; + } + + /** + * The number of characters added at the end of the paragraph which contains the start position. + * @return the number of characters inserted + */ + public int getCharsAddedTop() { + return 0; + } + + /** + * The number of whole paragraphs inserted. + * @return the number of paragraphs + */ + public int getLinesAdded() { + return 0; + } + + /** + * The number of characters added at the beginning of the existing paragraph which contains the end position. + * @return the number of characters + */ + public int getCharsAddedBottom() { + return 0; + } + + /** + * Determines whether the change is an edit (true) or affects styling only (false). + * @return true if change affects stylint only + */ + public boolean isEdit() { + return true; + } + + /** + * Creates the content change event which represents an edit. + * + * @param start the start position + * @param end the end position + * @param charsAddedTop the number of characters appended to the paragraph containing the start position + * @param linesAdded the number of full paragraphs inserted + * @param charsAddedBottom the number of characters inserted at the beginning of the paragraph containing the end position + * @return the change instance + */ + public static ContentChange ofEdit(TextPos start, TextPos end, int charsAddedTop, int linesAdded, int charsAddedBottom) { + return new ContentChange(start, end) { + @Override + public int getCharsAddedTop() { + return charsAddedTop; + } + + @Override + public int getLinesAdded() { + return linesAdded; + } + + @Override + public int getCharsAddedBottom() { + return charsAddedBottom; + } + }; + } + + /** + * Creates the content change event which represents a styling update. + * + * @param start the start position + * @param end the end position + * @return the change instance + */ + public static ContentChange ofStyleChange(TextPos start, TextPos end) { + return new ContentChange(start, end) { + @Override + public boolean isEdit() { + return false; + } + }; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/DataFormatHandler.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/DataFormatHandler.java new file mode 100644 index 00000000000..92f3cff18c9 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/DataFormatHandler.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.IOException; +import java.io.OutputStream; +import javafx.scene.input.DataFormat; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Facilitates import/export of styled text into/from a StyledTextModel. + */ +public abstract class DataFormatHandler { + /** + * Creates a StyledInput for the given input string. When pasting, the caller may pass + * the style attributes {@code attr} at the insertion point. This argument may be used by + * the implementation if the format contains no styles on its own (for example, in the plain text format case). + * + * @param input the input string + * @param attr the style attributes (can be null) + * @return the StyledInput + * @throws IOException when operation is not supported or an I/O error occurs + */ + public abstract StyledInput createStyledInput(String input, StyleAttrs attr) throws IOException; + + /** + * Creates an object to be put into the Clipboard for the given text range. + * The caller must guarantee that the {@code start} precedes the {@code end} position. + *

+ * Typically, the implementation creates an instance of {@link StyledOutput} and calls + * {@link StyledTextModel#export(TextPos, TextPos, StyledOutput)} method. + * + * @param model source model + * @param resolver view-specific style resolver + * @param start start text position + * @param end end text position + * @return an object to be placed to the Clipboard + * @throws IOException when an I/O error occurs + */ + // TODO throw UnsupportedMethodException if export operation is not supported? + public abstract Object copy(StyledTextModel model, StyleResolver resolver, TextPos start, TextPos end) + throws IOException; + + /** + * Save the text range in the handler's format to the output stream (e.g. save to file). + * The caller must guarantee that the {@code start} precedes the {@code end} position. + * It is the responsibility of the caller to close the {@code OutputStream}. + *

+ * Typically, the implementation creates an instance of {@link StyledOutput} and calls + * {@link StyledTextModel#export(TextPos, TextPos, StyledOutput)} method. + * + * @param model source model + * @param resolver view-specific style resolver + * @param start start text position + * @param end end text position + * @param out target {@code OutputStream} + * @throws IOException when an I/O error occurs + */ + // TODO throw UnsupportedMethodException if export operation is not supported? + public abstract void save( + StyledTextModel model, + StyleResolver resolver, + TextPos start, + TextPos end, + OutputStream out) throws IOException; + + private final DataFormat format; + + /** + * Creates a DataHandler instance for the specified format. + * @param f data format + */ + public DataFormatHandler(DataFormat f) { + this.format = f; + } + + /** + * Returns the {@link DataFormat} associated with this handler. + * @return the data format + */ + public final DataFormat getDataFormat() { + return format; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/EditableRichTextModel.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/EditableRichTextModel.java new file mode 100644 index 00000000000..812e7f05875 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/EditableRichTextModel.java @@ -0,0 +1,790 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.function.Function; +import java.util.function.Supplier; +import javafx.scene.layout.Region; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Editable, in-memory {@link StyledTextModel} based on a collection of styled segments. + *

+ * This model is suitable for relatively small documents as it has neither disk storage backing + * nor storage of incremental changes. + */ +public class EditableRichTextModel extends StyledTextModel { + private final ArrayList paragraphs = new ArrayList<>(); + private final HashMap styleCache = new HashMap<>(); + + /** + * Constructs the empty model. + */ + public EditableRichTextModel() { + registerDataFormatHandler(new RichTextFormatHandler(), true, true, 2000); + registerDataFormatHandler(new RtfFormatHandler(), true, true, 1000); + registerDataFormatHandler(new HtmlExportFormatHandler(), true, false, 100); + registerDataFormatHandler(new PlainTextFormatHandler(), true, true, 0); + // always has at least one paragraph + paragraphs.add(new RParagraph()); + } + + @Override + public final boolean isUserEditable() { + return true; + } + + @Override + public int size() { + return paragraphs.size(); + } + + @Override + public String getPlainText(int index) { + return paragraphs.get(index).getPlainText(); + } + + @Override + public int getTextLength(int index) { + return paragraphs.get(index).getTextLength(); + } + + @Override + public RichParagraph getParagraph(int index) { + RParagraph p = paragraphs.get(index); + return p.createRichParagraph(); + } + + @Override + protected int insertTextSegment(int index, int offset, String text, StyleAttrs attrs) { + attrs = dedup(attrs); + RParagraph par = paragraphs.get(index); + par.insertText(offset, text, attrs); + return text.length(); + } + + @Override + protected void insertLineBreak(int index, int offset) { + if (index >= size()) { + // unlikely to happen + RParagraph par = new RParagraph(); + paragraphs.add(par); + } else { + RParagraph par = paragraphs.get(index); + RParagraph par2 = par.insertLineBreak(offset); + paragraphs.add(index + 1, par2); + } + } + + @Override + protected void removeRange(TextPos start, TextPos end) { + int ix = start.index(); + RParagraph par = paragraphs.get(ix); + + if (ix == end.index()) { + par.removeSpan(start.offset(), end.offset()); + } else { + RParagraph last = paragraphs.get(end.index()); + last.removeSpan(0, end.offset()); + + par.removeSpan(start.offset(), Integer.MAX_VALUE); + par.append(last); + + int ct = end.index() - ix; + ix++; + for (int i = 0; i < ct; i++) { + paragraphs.remove(ix); + } + } + } + + @Override + protected void insertParagraph(int index, Supplier generator) { + // TODO + } + + /** deduplicates style attributes. */ + private StyleAttrs dedup(StyleAttrs a) { + // the expectation is that the number of different style combinations is relatively low + // but the number of instances can be large + // the drawback is that there is no way to clear the cache + StyleAttrs cached = styleCache.get(a); + if (cached != null) { + return cached; + } + styleCache.put(a, a); + return a; + } + + @Override + protected void setParagraphStyle(int ix, StyleAttrs attrs) { + paragraphs.get(ix).setParagraphAttributes(attrs); + } + + @Override + protected void applyStyle(int ix, int start, int end, StyleAttrs attrs, boolean merge) { + paragraphs.get(ix).applyStyle(start, end, attrs, merge, this::dedup); + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver resolver, TextPos pos) { + int index = pos.index(); + if (index < paragraphs.size()) { + int off = pos.offset(); + RParagraph par = paragraphs.get(index); + StyleAttrs pa = par.getParagraphAttributes(); + StyleAttrs a = par.getStyleAttrs(off); + if (pa == null) { + return a; + } else { + return pa.combine(a); + } + } + return StyleAttrs.EMPTY; + } + + /** + * Temporary method added for testing; will be removed in production. + * @param model the model + * @param out the output + */ + // TODO remove later + @Deprecated + public static void dump(StyledTextModel model, PrintStream out) { + if (model instanceof EditableRichTextModel m) { + m.dump(out); + } + } + + private void dump(PrintStream out) { + out.println("["); + for (RParagraph p : paragraphs) { + dump(p, out); + } + out.println("]"); + } + + private void dump(RParagraph p, PrintStream out) { + out.println(" {paragraphAttrs=" + p.getParagraphAttributes() + ", segments=["); + for(RSegment s: p) { + out.println(" {text=\"" + s.text() + "\", attr=" + s.getStyleAttrs() + "},"); + } + out.println(" ]}"); + } + + /** + * Represents a rich text segment having the same style attributes. + */ + private static class RSegment { + private String text; + private StyleAttrs attrs; + + public RSegment(String text, StyleAttrs attrs) { + this.text = text; + this.attrs = attrs; + } + + public String text() { + return text; + } + + public StyleAttrs attrs() { + return attrs; + } + + public void setAttrs(StyleAttrs a) { + attrs = a; + } + + public StyleAttrs getStyleAttrs() { + return attrs; + } + + public int getTextLength() { + return text.length(); + } + + /** returns true if this segment becomes empty as a result */ + // TODO unit test + public boolean removeRegion(int start, int end) { + int len = text.length(); + if (end > len) { + end = len; + } + + if (start == 0) { + if (end < len) { + text = text.substring(end); + } else { + text = ""; + } + } else { + if (end < len) { + text = text.substring(0, start) + text.substring(end, len); + } else { + text = text.substring(0, start); + } + } + + return (text.length() == 0); + } + + public void append(String s) { + text = text + s; + } + + public void setText(String s) { + text = s; + } + } + + /** + * Model paragraph is a list of RSegments. + */ + static class RParagraph extends ArrayList { + + private StyleAttrs paragraphAttrs; + + /** Creates an instance */ + public RParagraph() { + } + + public StyleAttrs getParagraphAttributes() { + return paragraphAttrs; + } + + public void setParagraphAttributes(StyleAttrs a) { + paragraphAttrs = a; + } + + public String getPlainText() { + StringBuilder sb = new StringBuilder(); + for(RSegment s: this) { + sb.append(s.text()); + } + return sb.toString(); + } + + public int getTextLength() { + int len = 0; + for(RSegment s: this) { + len += s.getTextLength(); + } + return len; + } + + /** + * Retrieves the style attributes from the previous character (or next, if at the beginning). + * @param offset the offset + * @return the style info + */ + public StyleAttrs getStyleAttrs(int offset) { + int off = 0; + int ct = size(); + for (int i = 0; i < ct; i++) { + RSegment seg = get(i); + int len = seg.getTextLength(); + if (offset < (off + len) || (i == ct - 1)) { + return seg.getStyleAttrs(); + } + off += len; + } + return StyleAttrs.EMPTY; + } + + /** + * Inserts styled text at the specified offset. + * @param offset the insertion offset + * @param text the plain text + * @param attrs the style attributes + */ + public void insertText(int offset, String text, StyleAttrs attrs) { + int off = 0; + int ct = size(); + for (int i = 0; i < ct; i++) { + if (offset == off) { + // insert at the beginning + insertSegment2(i, text, attrs); + return; + } else { + RSegment seg = get(i); + int len = seg.getTextLength(); + if ((offset > off) && (offset <= off + len)) { + // split segment + StyleAttrs a = seg.attrs(); + String toSplit = seg.text(); + int ix = offset - off; + + String s1 = toSplit.substring(0, ix); + set(i++, new RSegment(s1, a)); + if (insertSegment2(i, text, attrs)) { + i++; + } + if (ix < toSplit.length()) { + String s2 = toSplit.substring(ix); + insertSegment2(i, s2, a); + } + return; + } + + off += len; + } + } + + // insert at the end + insertSegment2(ct, text, attrs); + } + + /** + * Inserts a new segment, or merges with adjacent segment if styles are the same. + * Returns true if a segment has been added. + * @param ix the segment index + * @param text the plain text + * @param a the style attributes + * @return true if a segment has been added. + */ + private boolean insertSegment2(int ix, String text, StyleAttrs a) { + if (ix == 0) { + // FIX aaaa combine with insertSegment + if (ix < size()) { + RSegment seg = get(ix); + if (seg.getTextLength() == 0) { + // replace zero width segment + seg.setText(text); + seg.setAttrs(a); + return false; + } else if (a.equals(seg.attrs())) { + // combine + seg.setText(text + seg.text()); + return false; + } + } + } else if (ix > 0) { + RSegment prev = get(ix - 1); + if (a.equals(prev.attrs())) { + // combine + prev.append(text); + return false; + } + } + + RSegment seg = new RSegment(text, a); + if (ix < size()) { + add(ix, seg); + } else { + add(seg); + } + return true; + } + + /** + * inserts a new segment with the specified, deduplicated attributes. + * if the new style is the same as the previous segment, merges text with the previous segment instead. + * @return true if the new segment has been merged with the previous segment + */ + // TODO should it also merge with the next segment if the styles are the same? + // in this case it's better to return an int which is the amount of segments added/removed + private boolean insertSegment(int ix, String text, StyleAttrs a) { + // TODO deal with zero width segment + // FIX aaaa combine with insertSegment2 + if (ix > 0) { + RSegment prev = get(ix - 1); + if (prev.attrs().equals(a)) { + // merge + prev.append(text); + return true; + } + } + RSegment seg = new RSegment(text, a); + if (ix >= size()) { + add(seg); + } else { + add(ix, seg); + } + return false; + } + + /** + * Trims this paragraph and returns the remaining text to be inserted after the line break. + * @param offset the offset + * @return the remaining portion of paragraph + */ + public RParagraph insertLineBreak(int offset) { + RParagraph next = new RParagraph(); + next.setParagraphAttributes(getParagraphAttributes()); + + int off = 0; + int i; + int ct = size(); + for (i = 0; i < ct; i++) { + RSegment seg = get(i); + int len = seg.getTextLength(); + if (offset < (off + len)) { + if (offset != off) { + // split segment + StyleAttrs a = seg.attrs(); + String toSplit = seg.text(); + int ix = offset - off; + String s1 = toSplit.substring(0, ix); + String s2 = toSplit.substring(ix); + set(i, new RSegment(s1, a)); + + next.add(new RSegment(s2, a)); + i++; + } + break; + } + off += len; + } + + // move remaining segments to the next paragraph + while (i < size()) { + RSegment seg = remove(i); + next.add(seg); + } + + // preserve attributes using zero width segment + if (size() == 0) { + if (next.size() > 0) { + StyleAttrs a = next.get(0).getStyleAttrs(); + add(new RSegment("", a)); + } + } + if (next.size() == 0) { + if (size() > 0) { + StyleAttrs a = get(size() - 1).getStyleAttrs(); + next.add(new RSegment("", a)); + } + } + + return next; + } + + /** + * Appends the specified paragraph by adding all of its segments. + * @param p the paragraph to append + */ + public void append(RParagraph p) { + if (isMerge(p)) { + int sz = p.size(); + for(int i=0; i dedup) { + int off = 0; + int i = 0; + for ( ; i < size(); i++) { + RSegment seg = get(i); + int len = seg.getTextLength(); + int cs = whichCase(off, off + len, start, end); + switch (cs) { + case 0: + break; + case 1: + case 2: + if (applyStyle(i, seg, attrs, merge, dedup)) { + i--; + } + break; + case 3: + case 9: + applyStyle(i, seg, attrs, merge, dedup); + return; + case 4: + case 8: + // split + { + StyleAttrs a = seg.attrs(); + StyleAttrs newAttrs = dedup.apply(merge ? a.combine(attrs) : attrs); + int ix = end - off; + String s1 = seg.text().substring(0, ix); + String s2 = seg.text().substring(ix); + remove(i); + if (insertSegment(i++, s1, newAttrs)) { + i--; + } + if (insertSegment(i, s2, a)) { + i--; + } + } + return; + case 5: + case 6: + // split + { + StyleAttrs a = seg.attrs(); + StyleAttrs newAttrs = dedup.apply(merge ? a.combine(attrs) : attrs); + int ix = start - off; + String s1 = seg.text().substring(0, ix); + String s2 = seg.text().substring(ix); + remove(i); + if (insertSegment(i++, s1, a)) { + i--; + } + if (insertSegment(i, s2, newAttrs)) { + i--; + } + } + if (cs == 6) { + return; + } + break; + case 7: + { + StyleAttrs a = seg.attrs(); + StyleAttrs newAttrs = dedup.apply(merge ? a.combine(attrs) : attrs); + String text = seg.text(); + int ix0 = start - off; + int ix1 = end - off; + String s1 = text.substring(0, ix0); + String s2 = text.substring(ix0, ix1); + String s3 = text.substring(ix1); + remove(i); + if (insertSegment(i++, s1, a)) { + i--; + } + if (insertSegment(i++, s2, newAttrs)) { + i--; + } + if (insertSegment(i, s3, a)) { + i--; + } + } + return; + default: + throw new Error("?" + cs); + } + + off += len; + } + } + + /** + * Applies style to the segment. + * If the new style is exactly the same as the style of the previous segment, + * it simply merges the two segments. + * @param ix the paragraph index + * @param seg the segment + * @param a the attributes + * @param merge whether to merge or set + * @param dedup the deduplicator + * @return true if this segment has been merged with the previous segment + */ + private boolean applyStyle(int ix, RSegment seg, StyleAttrs a, boolean merge, Function dedup) { + StyleAttrs newAttrs = dedup.apply(merge ? seg.attrs().combine(a) : a); + if (ix > 0) { + RSegment prev = get(ix - 1); + if (prev.attrs().equals(newAttrs)) { + // merge + prev.append(seg.text()); + remove(ix); + return true; + } + } + seg.setAttrs(newAttrs); + return false; + } + + /** + *

+         * paragraph:    [=============]
+         * case:
+         *         0:                      |-
+         *         1:  -------------------->
+         *         2:    |----------------->
+         *         3:    |-------------|
+         *         4:    |--------|
+         *         5:        |------------->
+         *         6:        |---------|
+         *         7:        |----|
+         *         8:  -----------|
+         *         9:  ----------------|
+         */
+        private static int whichCase(int off, int max, int start, int end) {
+            // TODO unit test!
+            if (start >= max) {
+                return 0;
+            } else if (start < off) {
+                if (end > max) {
+                    return 1;
+                } else if (end < max) {
+                    return 8;
+                } else {
+                    return 9;
+                }
+            } else if (start > off) {
+                if (end > max) {
+                    return 5;
+                } else if (end < max) {
+                    return 7;
+                } else {
+                    return 6;
+                }
+            } else {
+                if (end > max) {
+                    return 2;
+                } else if (end < max) {
+                    return 4;
+                } else {
+                    return 3;
+                }
+            }
+        }
+
+        private RichParagraph createRichParagraph() {
+            RichParagraph.Builder b = RichParagraph.builder();
+            for (RSegment seg : this) {
+                String text = seg.text();
+                StyleAttrs a = seg.attrs();
+                b.addSegment(text, a);
+            }
+            b.setParagraphAttributes(paragraphAttrs);
+            return b.build();
+        }
+    }
+}
diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/HtmlExportFormatHandler.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/HtmlExportFormatHandler.java
new file mode 100644
index 00000000000..05b8da0e107
--- /dev/null
+++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/HtmlExportFormatHandler.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package jfx.incubator.scene.control.rich.model;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import javafx.scene.input.DataFormat;
+import com.sun.jfx.incubator.scene.control.rich.HtmlStyledOutput;
+import jfx.incubator.scene.control.rich.StyleResolver;
+import jfx.incubator.scene.control.rich.TextPos;
+
+/**
+ * This partial {@link DataFormatHandler} supports export of styled text in a simple HTML format.
+ */
+public class HtmlExportFormatHandler extends DataFormatHandler {
+    /** when true, style attributes are inlined, this seems to work better in Thunderbird */
+    private static final boolean INLINE_STYLES = true;
+
+    /** The constructor */
+    public HtmlExportFormatHandler() {
+        super(DataFormat.HTML);
+    }
+
+    @Override
+    public StyledInput createStyledInput(String input, StyleAttrs attr) {
+        throw new UnsupportedOperationException("import from HTML is not supported by this DataFormatHandler");
+    }
+
+    @Override
+    public Object copy(StyledTextModel model, StyleResolver resolver, TextPos start, TextPos end) throws IOException {
+        StringWriter wr = new StringWriter(65536);
+        export(model, resolver, start, end, wr);
+        return wr.toString();
+    }
+
+    @Override
+    public void save(StyledTextModel model, StyleResolver resolver, TextPos start, TextPos end, OutputStream out)
+        throws IOException {
+        Charset ascii = Charset.forName("ASCII");
+        OutputStreamWriter wr = new OutputStreamWriter(out, ascii);
+        export(model, resolver, start, end, wr);
+    }
+
+    private void export(StyledTextModel model, StyleResolver resolver, TextPos start, TextPos end, Writer wr)
+        throws IOException {
+        HtmlStyledOutput out = new HtmlStyledOutput(resolver, wr, INLINE_STYLES);
+        // collect styles
+        model.export(start, end, out.firstPassBuilder());
+
+        out.writePrologue();
+        model.export(start, end, out);
+        out.writeEpilogue();
+        out.flush();
+    }
+}
diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ImageCellPane.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ImageCellPane.java
new file mode 100644
index 00000000000..de406d5e2a5
--- /dev/null
+++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ImageCellPane.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package jfx.incubator.scene.control.rich.model;
+
+import javafx.geometry.HPos;
+import javafx.geometry.Insets;
+import javafx.geometry.VPos;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.Pane;
+
+/**
+ * Content pane for RichParagraph that shows a single image.
+ * The image gets resized if it cannot fit into available width.
+ */
+public class ImageCellPane extends Pane {
+    private final Image image;
+    private final ImageView imageView;
+    private static final Insets PADDING = new Insets(1, 1, 1, 1);
+
+    /**
+     * The constructor.
+     * @param image the image
+     */
+    public ImageCellPane(Image image) {
+        this.image = image;
+
+        imageView = new ImageView(image);
+        imageView.setSmooth(true);
+        imageView.setPreserveRatio(true);
+        getChildren().add(imageView);
+
+        setPadding(PADDING);
+        getStyleClass().add("image-cell-pane");
+    }
+
+    @Override
+    protected void layoutChildren() {
+        double width = getWidth();
+        double sc;
+        if (width < image.getWidth()) {
+            sc = width / image.getWidth();
+        } else {
+            sc = 1.0;
+        }
+        imageView.setScaleX(sc);
+        imageView.setScaleY(sc);
+
+        double x0 = snappedLeftInset();
+        double y0 = snappedTopInset();
+        layoutInArea(
+            imageView,
+            x0,
+            y0,
+            image.getWidth() * sc,
+            image.getHeight() * sc,
+            0,
+            PADDING,
+            true,
+            false,
+            HPos.CENTER,
+            VPos.CENTER
+        );
+    }
+
+    @Override
+    protected double computePrefHeight(double w) {
+        double pad = snappedTopInset() + snappedBottomInset();
+        if (w != -1) {
+            if (w < image.getWidth()) {
+                return pad + (image.getHeight() * w / image.getWidth());
+            }
+        }
+        return pad + (image.getHeight());
+    }
+}
diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ParagraphDirection.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ParagraphDirection.java
new file mode 100644
index 00000000000..6b700bf36c8
--- /dev/null
+++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/ParagraphDirection.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package jfx.incubator.scene.control.rich.model;
+
+/**
+ * Paragraph direction attribute.
+ */
+public enum ParagraphDirection {
+    /** Indicates the left-to-right writing direction. */
+    LEFT_TO_RIGHT,
+    /** Indicates the right-to-left writing direction. */
+    RIGHT_TO_LEFT
+}
diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/PlainTextFormatHandler.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/PlainTextFormatHandler.java
new file mode 100644
index 00000000000..48c9ee535a1
--- /dev/null
+++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/PlainTextFormatHandler.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package jfx.incubator.scene.control.rich.model;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import javafx.scene.input.DataFormat;
+import com.sun.jfx.incubator.scene.control.rich.StringBuilderStyledOutput;
+import jfx.incubator.scene.control.rich.StyleResolver;
+import jfx.incubator.scene.control.rich.TextPos;
+
+/**
+ * {@link DataFormatHandler} which operates with plain text.
+ */
+public class PlainTextFormatHandler extends DataFormatHandler {
+    /** The constructor. */
+    public PlainTextFormatHandler() {
+        super(DataFormat.PLAIN_TEXT);
+    }
+
+    @Override
+    public StyledInput createStyledInput(String text, StyleAttrs attr) {
+        return StyledInput.of(text, attr);
+    }
+
+    @Override
+    public Object copy(StyledTextModel m, StyleResolver resolver, TextPos start, TextPos end) throws IOException {
+        StringBuilderStyledOutput out = new StringBuilderStyledOutput();
+        m.export(start, end, out);
+        return out.toString();
+    }
+
+    @Override
+    public void save(StyledTextModel m, StyleResolver resolver, TextPos start, TextPos end, OutputStream out) throws IOException {
+        Charset charset = Charset.forName("utf-8");
+        byte[] newline = System.getProperty("line.separator").getBytes(charset);
+
+        StyledOutput so = new StyledOutput() {
+            @Override
+            public void append(StyledSegment seg) throws IOException {
+                switch (seg.getType()) {
+                case LINE_BREAK:
+                    out.write(newline);
+                    break;
+                case TEXT:
+                    String text = seg.getText();
+                    byte[] b = text.getBytes(charset);
+                    out.write(b);
+                    break;
+                }
+            }
+
+            @Override
+            public void flush() throws IOException {
+                out.flush();
+            }
+
+            @Override
+            public void close() throws IOException {
+                out.close();
+            }
+        };
+        m.export(start, end, so);
+        out.flush();
+    }
+}
diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/PlainTextModel.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/PlainTextModel.java
new file mode 100644
index 00000000000..94845dc057f
--- /dev/null
+++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/PlainTextModel.java
@@ -0,0 +1,290 @@
+/*
+ * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package jfx.incubator.scene.control.rich.model;
+
+import java.util.ArrayList;
+import java.util.function.Supplier;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.scene.layout.Region;
+import jfx.incubator.scene.control.rich.StyleResolver;
+import jfx.incubator.scene.control.rich.TextPos;
+
+/**
+ * A StyledTextModel based on plain text paragraphs.
+ * 

+ * This class provides no styling. Subclasses might override {@link #getParagraph(int)} to provide + * syntax highlighting based on the model content. + *

+ * This model supports custom content storage mechanism via {@link PlainTextModel.Content}. By default, + * the model provides an in-memory storage via its {@link PlainTextModel.InMemoryContent} implementation. + */ +public class PlainTextModel extends StyledTextModel { + /** + * This interface describes the underlying storage mechanism for the PlainTextModel. + */ + public interface Content { + /** + * Returns the number of paragraphs in this content. + * @return number of text lines + */ + public int size(); + + /** + * Returns the text string for the specified paragraph index. The returned text string cannot be null + * and must not contain any control characters other than TAB. + * The caller should never attempt to ask for a paragraph outside of the valid range. + * + * @param index the paragraph index in the range (0...{@link #size()}) + * @return the text string or null + */ + public String getText(int index); + + /** + * This method is called to insert a single text segment at the given position. + * + * @param index the paragraph index + * @param offset the insertion offset within the paragraph + * @param text the text to insert + * @param attrs the style attributes + * @return the number of characters inserted + */ + public int insertTextSegment(int index, int offset, String text, StyleAttrs attrs); + + /** + * Inserts a line break. + * + * @param index the model index + * @param offset the text offset + */ + public void insertLineBreak(int index, int offset); + + /** + * This method gets called only if the model is editable. + * The caller guarantees that {@code start} precedes {@code end}. + * + * @param start the start of the region to be removed + * @param end the end of the region to be removed, expected to be greater than the start position + */ + public void removeRange(TextPos start, TextPos end); + + /** + * Determines whether this content supports modification by the user. + * @return true if editable + */ + public boolean isUserEditable(); + } + + private final Content content; + + /** + * Constructs an empty model with the specified {@code Content}. + * @param c the content to use + */ + public PlainTextModel(Content c) { + this.content = c; + registerDataFormatHandler(new PlainTextFormatHandler(), true, true, 0); + } + + /** + * Constructs an empty model with the in-memory {@code Content}. + */ + public PlainTextModel() { + this(new InMemoryContent()); + } + + /** + * Inserts text at the specified position. + * This is a convenience shortcut for {@link #replace(StyleResolver, TextPos, TextPos, String, boolean)}. + * @param p the insertion position + * @param text the text to insert + */ + public void insertText(TextPos p, String text) { + replace(null, p, p, text, false); + } + + @Override + public int size() { + return content.size(); + } + + @Override + public String getPlainText(int index) { + return content.getText(index); + } + + @Override + public RichParagraph getParagraph(int index) { + String text = getPlainText(index); + return RichParagraph.builder(). + addSegment(text). + build(); + } + + /** + * Determines whether the model is user-editable. + *

+ * This method calls {@link PlainTextModel.Content#isUserEditable()}. + * + * @return true if the model is user-editable + */ + @Override + public final boolean isUserEditable() { + return content.isUserEditable(); + } + + @Override + protected int insertTextSegment(int index, int offset, String text, StyleAttrs attrs) { + return content.insertTextSegment(index, offset, text, attrs); + } + + @Override + protected void insertLineBreak(int index, int offset) { + content.insertLineBreak(index, offset); + } + + @Override + protected void removeRange(TextPos start, TextPos end) { + content.removeRange(start, end); + } + + @Override + protected void insertParagraph(int index, Supplier generator) { + // no-op + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver resolver, TextPos pos) { + return StyleAttrs.EMPTY; + } + + @Override + protected final void setParagraphStyle(int ix, StyleAttrs a) { + // no-op + } + + @Override + protected final void applyStyle(int ix, int start, int end, StyleAttrs a, boolean merge) { + // no-op + } + + /** + * This content provides in-memory storage in an {@code ArrayList} of {@code String}s. + */ + public static class InMemoryContent implements Content { + private final ArrayList paragraphs = new ArrayList<>(); + + /** The constructor. */ + public InMemoryContent() { + } + + @Override + public int size() { + int sz = paragraphs.size(); + // empty model always have one line + return sz == 0 ? 1 : sz; + } + + @Override + public String getText(int index) { + if (index < paragraphs.size()) { + return paragraphs.get(index); + } + return ""; + } + + @Override + public int insertTextSegment(int index, int offset, String text, StyleAttrs attrs) { + String s = getText(index); + String s2 = insertText(s, offset, text); + setText(index, s2); + return text.length(); + } + + private static String insertText(String text, int offset, String toInsert) { + if (offset >= text.length()) { + return text + toInsert; + } else { + return text.substring(0, offset) + toInsert + text.substring(offset); + } + } + + @Override + public void insertLineBreak(int index, int offset) { + if (index >= paragraphs.size()) { + paragraphs.add(""); + } else { + String s = paragraphs.get(index); + if (offset >= s.length()) { + paragraphs.add(index + 1, ""); + } else { + setText(index, s.substring(0, offset)); + paragraphs.add(index + 1, s.substring(offset)); + } + } + } + + @Override + public void removeRange(TextPos start, TextPos end) { + int ix = start.index(); + String text = getText(ix); + String newText; + + if (ix == end.index()) { + int len = text.length(); + if (end.offset() >= len) { + newText = text.substring(0, start.offset()); + } else { + newText = text.substring(0, start.offset()) + text.substring(end.offset()); + } + setText(ix, newText); + } else { + newText = text.substring(0, start.offset()) + paragraphs.get(end.index()).substring(end.offset()); + setText(ix, newText); + + int ct = end.index() - ix; + ix++; + for (int i = 0; i < ct; i++) { + paragraphs.remove(ix); + } + } + } + + private void setText(int index, String text) { + if (index < paragraphs.size()) { + paragraphs.set(index, text); + } else { + // due to emulated empty paragraph in an empty model + paragraphs.add(text); + } + } + + @Override + public boolean isUserEditable() { + return true; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RichParagraph.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RichParagraph.java new file mode 100644 index 00000000000..836501c267b --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RichParagraph.java @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.scene.Node; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import com.sun.jfx.incubator.scene.control.rich.RichParagraphHelper; +import com.sun.jfx.incubator.scene.control.rich.TextCell; +import jfx.incubator.scene.control.rich.StyleResolver; + +/** + * Represents a single immutable paragraph within the {@code StyledModel}. + * A single paragraph may contain either: + *

    + *
  • A number of {@code StyledSegments} such as styled text or {@code Supplier}s of embedded {@code Node}s + *
  • A supplier of a single {@code Region} which fills the entire paragraph + *
+ */ +public abstract class RichParagraph { + static { initAccessor(); } + + /** + * The constructor. + */ + public RichParagraph() { + } + + /** + * Creates a paragraph consisting of a single Rectangle. + * The paragraph will typically assume its Rectangle preferred size, or, + * when the text wrap mode is on, might get resized to fit the available width. + *

+ * The supplied generator must not cache or keep reference to the created Node, + * but the created Node can keep a reference to the model or a property therein. + *

+ * For example, a bidirectional binding between an inline control and some property in the model + * would synchronize the model with all the views that use it. + * + * @param paragraphGenerator the content generator + * @return the RichParagraph instance + */ + public static RichParagraph of(Supplier paragraphGenerator) { + return new RichParagraph() { + @Override + public final Supplier getParagraphRegion() { + return paragraphGenerator; + } + + @Override + public final String getPlainText() { + return ""; + } + + @Override + public void export(int start, int end, StyledOutput out) throws IOException { + StyledSegment seg = StyledSegment.ofRegion(paragraphGenerator); + out.append(seg); + } + + @Override + List getSegments() { + return null; + } + }; + } + + /** + * Returns the generator for this paragraph {@code Region} representation. + * This method returns a non-null value when the paragraph is represented by a single {@code Region}. + * + * @return the generator, or null + */ + public Supplier getParagraphRegion() { + return null; + } + + /** + * Returns the plain text of this paragraph, or null. + * @return the plain text + */ + public abstract String getPlainText(); + + // this method could be made public, as long as the returned list is made immutable + abstract List getSegments(); + + /** + * Returns the paragraph attributes. + * @return the paragraph attributes, can be null + */ + public StyleAttrs getParagraphAttributes() { + return null; + } + + List> getHighlights() { + return null; + } + + // for use by StyledTextModel + void export(int start, int end, StyledOutput out) throws IOException { + List segments = getSegments(); + if (segments == null) { + out.append(StyledSegment.of("")); + } else { + int off = 0; + int sz = segments.size(); + for (int i = 0; i < sz; i++) { + StyledSegment seg = segments.get(i); + String text = seg.getText(); + int len = (text == null ? 0 : text.length()); + if (start <= (off + len)) { + int ix0 = Math.max(0, start - off); + int ix1 = Math.min(len, end - off); + if (ix1 > ix0) { + StyledSegment ss = seg.subSegment(ix0, ix1); + out.append(ss); + } + } + off += len; + if (off >= end) { + return; + } + } + } + } + + // for use by SimpleReadOnlyStyledModel + StyleAttrs getStyleAttrs(StyleResolver resolver, int offset) { + int off = 0; + List segments = getSegments(); + if (segments != null) { + int sz = segments.size(); + for (int i = 0; i < sz; i++) { + StyledSegment seg = segments.get(i); + int len = seg.getTextLength(); + if (offset < (off + len) || (i == sz - 1)) { + return seg.getStyleAttrs(resolver); + } + off += len; + } + } + return StyleAttrs.EMPTY; + } + + private static void initAccessor() { + RichParagraphHelper.setAccessor(new RichParagraphHelper.Accessor() { + @Override + public List getSegments(RichParagraph p) { + return p.getSegments(); + } + + @Override + public List> getHighlights(RichParagraph p) { + return p.getHighlights(); + } + }); + } + + /** + * Creates an instance of the {@code Builder} class. + * @return the new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Utility class for building immutable {@code RichParagraph}s. + */ + public static class Builder { + private ArrayList segments; + private ArrayList> highlights; + private StyleAttrs paragraphAttributes; + + Builder() { + } + + /** + * Adds a squiggly line (as seen in a spell checker) with the given color. + * @param start the start offset + * @param length the end offset + * @param color the background color + * @return this {@code Builder} instance + */ + public Builder addSquiggly(int start, int length, Color color) { + int end = start + length; + highlights().add((cell) -> { + cell.addSquiggly(start, end, color); + }); + return this; + } + + private List> highlights() { + if (highlights == null) { + highlights = new ArrayList<>(4); + } + return highlights; + } + + /** + * Adds a text segment with no styling (i.e. using default style). + * + * @param text segment text + * @return this {@code Builder} instance + */ + public Builder addSegment(String text) { + StyledSegment seg = StyledSegment.of(text); + segments().add(seg); + return this; + } + + /** + * Adds a styled text segment. + * + * @param text non-null text string + * @param style direct style (such as {@code -fx-fill:red;}), or null + * @param css array of style names, or null + * @return this {@code Builder} instance + */ + public Builder addSegment(String text, String style, String[] css) { + StyleAttrs a = StyleAttrs.fromStyles(style, css); + addSegment(text, a); + return this; + } + + /** + * Adds a styled text segment. + * @param text the non-null text string + * @param attrs the styled attributes + * @return this {@code Builder} instance + */ + public Builder addSegment(String text, StyleAttrs attrs) { + StyledSegment seg = StyledSegment.of(text, attrs); + segments().add(seg); + return this; + } + + /** + * Adds a styled text segment. + * @param text the source non-null string + * @param start the start offset of the input string + * @param end the end offset of the input string + * @param attrs the styled attributes + * @return this {@code Builder} instance + */ + public Builder addSegment(String text, int start, int end, StyleAttrs attrs) { + String s = text.substring(start, end); + addSegment(s, attrs); + return this; + } + + /** + * Adds a color background highlight. + * Use translucent colors to enable multiple highlights in the same region of text. + * @param start the start offset + * @param length the end offset + * @param color the background color + * @return this {@code Builder} instance + */ + public Builder addHighlight(int start, int length, Color color) { + int end = start + length; + highlights().add((cell) -> { + cell.addHighlight(start, end, color); + }); + return this; + } + + /** + * Adds an inline node. + *

+ * The supplied generator must not cache or keep reference to the created Node, + * but the created Node can keep a reference to the model or some property therein. + *

+ * For example, a bidirectional binding between an inline control and some property in the model + * would synchronize the model with all the views that use it. + * @param generator the generator that provides the actual {@code Node} + * @return this {@code Builder} instance + */ + public Builder addInlineNode(Supplier generator) { + StyledSegment seg = StyledSegment.ofInlineNode(generator); + segments().add(seg); + return this; + } + + private List segments() { + if (segments == null) { + segments = new ArrayList<>(8); + } + return segments; + } + + /** + * Sets the paragraph attributes. + * @param a the paragraph attributes + * @return this {@code Builder} instance + */ + public Builder setParagraphAttributes(StyleAttrs a) { + paragraphAttributes = a; + return this; + } + + /** + * Creates an instance of immutable {@code RichParagraph} from information + * in this {@code Builder}. + * @return the new paragraph instance + */ + public RichParagraph build() { + return new RichParagraph() { + @Override + public StyleAttrs getParagraphAttributes() { + return paragraphAttributes; + } + + @Override + List getSegments() { + return segments; + } + + @Override + public String getPlainText() { + if (segments == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (StyledSegment seg : segments) { + sb.append(seg.getText()); + } + return sb.toString(); + } + + @Override + List> getHighlights() { + return highlights; + } + }; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RichTextFormatHandler.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RichTextFormatHandler.java new file mode 100644 index 00000000000..fbefbdd5fa6 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RichTextFormatHandler.java @@ -0,0 +1,639 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import javafx.scene.input.DataFormat; +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import javafx.util.StringConverter; +import javafx.util.converter.DoubleStringConverter; +import com.sun.jfx.incubator.scene.control.rich.Converters; +import com.sun.jfx.incubator.scene.control.rich.RichTextFormatHandlerHelper; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * A DataFormatHandler for use with attribute-based rich text models. + *

+ * The handler uses a simple text-based format:

+ * (*) denotes an optional element. + *

+ * PARAGRAPH[]
+ *
+ * PARAGRAPH: {
+ *     PARAGRAPH_ATTRIBUTE[]*,
+ *     TEXT_SEGMENT[],
+ *     "\n"
+ * }
+ *
+ * PARAGRAPH_ATTRIBUTE: {
+ *     "{!"
+ *     (name)
+ *     ATTRIBUTE_VALUE[]*
+ *     "}"
+ * }
+ *
+ * ATTRIBUTE: {
+ *     "{"
+ *     (name)
+ *     ATTRIBUTE_VALUE[]*
+ *     "}"
+ * }
+ *
+ * ATTRIBUTE_VALUE: {
+ *     |
+ *     (value)
+ * }
+ *
+ * TEXT_SEGMENT: {
+ *     ATTRIBUTE[]*
+ *     (text string with escaped special characters)
+ * }
+ * 
+ * Attribute sequences are further deduplicated, using a single {number} token + * which specifies the index into the list of unique sets of attributes. + * Paragraph attribute sets are treated as separate from the segment attrubite sets. + *

+ * The following characters are escaped in text segments: {,%,} + * The escape format is %XX where XX is a hexadecimal value. + *

+ * Example: + *

+ * {c|ff00ff}text{b}bold{!rtl}\n
+ * {1}line 2{!0}\n
+ * 
+ */ +public class RichTextFormatHandler extends DataFormatHandler { + static { initAccessor(); } + + private static final boolean DEBUG = false; + + /** The data format identifier */ + public static final DataFormat DATA_FORMAT = new DataFormat("application/x-com-oracle-editable-rich-text"); + + private static final StringConverter BOOLEAN_CONVERTER = Converters.booleanConverter(); + private static final StringConverter COLOR_CONVERTER = Converters.colorConverter(); + private static final StringConverter DIRECTION_CONVERTER = Converters.paragraphDirectionConverter(); + private static final DoubleStringConverter DOUBLE_CONVERTER = new DoubleStringConverter(); + private static final StringConverter STRING_CONVERTER = Converters.stringConverter(); + private static final StringConverter TEXT_ALIGNMENT_CONVERTER = Converters.textAlignmentConverter(); + // String -> Handler + // StyleAttribute -> Handler + private final HashMap handlers = new HashMap<>(64); + + /** + * Constructs a new instance. + */ + public RichTextFormatHandler() { + this(DATA_FORMAT); + } + + /** + * Constructs a new instance. + * @param format the data format + */ + public RichTextFormatHandler(DataFormat format) { + super(format); + + addHandlerBoolean(StyleAttrs.BOLD, "b"); + addHandler(StyleAttrs.BACKGROUND, "bg", COLOR_CONVERTER); + addHandlerString(StyleAttrs.BULLET, "bullet"); + addHandlerString(StyleAttrs.FONT_FAMILY, "ff"); + addHandler(StyleAttrs.FIRST_LINE_INDENT, "firstIndent", DOUBLE_CONVERTER); + addHandler(StyleAttrs.FONT_SIZE, "fs", DOUBLE_CONVERTER); + addHandlerBoolean(StyleAttrs.ITALIC, "i"); + addHandler(StyleAttrs.LINE_SPACING, "lineSpacing", DOUBLE_CONVERTER); + addHandler(StyleAttrs.PARAGRAPH_DIRECTION, "dir", DIRECTION_CONVERTER); + addHandler(StyleAttrs.SPACE_ABOVE, "spaceAbove", DOUBLE_CONVERTER); + addHandler(StyleAttrs.SPACE_BELOW, "spaceBelow", DOUBLE_CONVERTER); + addHandler(StyleAttrs.SPACE_LEFT, "spaceLeft", DOUBLE_CONVERTER); + addHandler(StyleAttrs.SPACE_RIGHT, "spaceRight", DOUBLE_CONVERTER); + addHandlerBoolean(StyleAttrs.STRIKE_THROUGH, "ss"); + addHandler(StyleAttrs.TEXT_ALIGNMENT, "alignment", TEXT_ALIGNMENT_CONVERTER); + addHandler(StyleAttrs.TEXT_COLOR, "tc", COLOR_CONVERTER); + addHandlerBoolean(StyleAttrs.UNDERLINE, "u"); + } + + private static void initAccessor() { + RichTextFormatHandlerHelper.setAccessor(new RichTextFormatHandlerHelper.Accessor() { + @Override + public StyledOutput createStyledOutput(RichTextFormatHandler h, StyleResolver r, Writer wr) { + return h.createStyledOutput(r, wr); + } + }); + } + + @Override + public StyledInput createStyledInput(String input, StyleAttrs attr) { + return new RichStyledInput(input); + } + + @Override + public Object copy(StyledTextModel m, StyleResolver r, TextPos start, TextPos end) throws IOException { + StringWriter wr = new StringWriter(); + StyledOutput so = createStyledOutput(r, wr); + m.export(start, end, so); + return wr.toString(); + } + + @Override + public void save(StyledTextModel m, StyleResolver r, TextPos start, TextPos end, OutputStream out) throws IOException { + Charset cs = Charset.forName("utf-8"); + Writer wr = new OutputStreamWriter(out, cs); + StyledOutput so = createStyledOutput(r, wr); + m.export(start, end, so); + } + + private StyledOutput createStyledOutput(StyleResolver r, Writer wr) { + Charset cs = Charset.forName("utf-8"); + boolean buffered = isBuffered(wr); + if (buffered) { + return new RichStyledOutput(r, wr); + } else { + wr = new BufferedWriter(wr); + return new RichStyledOutput(r, wr); + } + } + + private static boolean isBuffered(Writer x) { + return + (x instanceof BufferedWriter) || + (x instanceof StringWriter); + } + + /** attribute handler */ + static class Handler { + private final String id; + private final StyleAttribute attribute; + private final StringConverter converter; + + public Handler(StyleAttribute attribute, String id, StringConverter converter) { + this.id = id; + this.attribute = attribute; + this.converter = converter; + } + + public String getId() { + return id; + } + + public StyleAttribute getStyleAttribute() { + return attribute; + } + + public boolean isAllowed(T value) { + return true; + } + + public String write(T value) { + return converter.toString(value); + } + + public T read(String s) { + return converter.fromString(s); + } + } + + private void addHandler(StyleAttribute a, String id, StringConverter converter) { + addHandler(new Handler(a, id, converter)); + } + + private void addHandler(Handler h) { + handlers.put(h.getStyleAttribute(), h); + handlers.put(h.getId(), h); + } + + private void addHandlerBoolean(StyleAttribute a, String id) { + addHandler(new Handler(a, id, BOOLEAN_CONVERTER) { + @Override + public boolean isAllowed(Boolean value) { + return Boolean.TRUE.equals(value); + } + }); + } + + private void addHandlerString(StyleAttribute a, String id) { + addHandler(new Handler(a, id, STRING_CONVERTER)); + } + + private static void log(Object x) { + if (DEBUG) { + System.err.println(x); + } + } + + /** exporter */ + private class RichStyledOutput implements StyledOutput { + private final StyleResolver resolver; + private final Writer wr; + private HashMap styles = new HashMap<>(); + + public RichStyledOutput(StyleResolver r, Writer wr) { + this.resolver = r; + this.wr = wr; + } + + @Override + public void append(StyledSegment seg) throws IOException { + switch (seg.getType()) { + case INLINE_NODE: + // TODO + log("ignoring embedded node"); + break; + case LINE_BREAK: + wr.write("\n"); + break; + case PARAGRAPH_ATTRIBUTES: + { + StyleAttrs attrs = seg.getStyleAttrs(resolver); + emitAttributes(attrs, true); + } + break; + case REGION: + // TODO + break; + case TEXT: + { + StyleAttrs attrs = seg.getStyleAttrs(resolver); + emitAttributes(attrs, false); + + String text = seg.getText(); + text = encode(text); + wr.write(text); + } + break; + } + } + + private void emitAttributes(StyleAttrs attrs, boolean forParagraph) throws IOException { + if ((attrs != null) && (!attrs.isEmpty())) { + Integer num = styles.get(attrs); + if (num == null) { + // new style, gets numbered and added to the cache + int sz = styles.size(); + styles.put(attrs, Integer.valueOf(sz)); + + ArrayList> as = new ArrayList<>(attrs.getAttributes()); + // sort by name to make serialized output stable + // the overhead is very low since this is done once per style + Collections.sort(as, new Comparator>() { + @Override + public int compare(StyleAttribute a, StyleAttribute b) { + String sa = a.getName(); + String sb = b.getName(); + return sa.compareTo(sb); + } + }); + + for (StyleAttribute a : as) { + Handler h = handlers.get(a); + try { + if (h != null) { + Object v = attrs.get(a); + if (h.isAllowed(v)) { + wr.write('{'); + if (forParagraph) { + wr.write('!'); + } + wr.write(h.getId()); + String ss = h.write(v); + if (ss != null) { + wr.write('|'); + wr.write(encode(ss)); + } + wr.write('}'); + } + continue; + } + } catch (Exception e) { + log(e); + } + // ignoring this attribute + log("failed to emit " + a + ", skipping"); + } + } else { + // cached style, emit the id + wr.write('{'); + if (forParagraph) { + wr.write('!'); + } + wr.write(String.valueOf(num)); + wr.write('}'); + } + } else if (forParagraph) { + // this special token clears the paragraph attributes + wr.write("{!}"); + } + } + + private static String encode(String text) { + if (text == null) { + return ""; + } + + int ix = indexOfSpecialChar(text); + if (ix < 0) { + return text; + } + + int len = text.length(); + StringBuilder sb = new StringBuilder(len + 32); + if (ix > 0) { + sb.append(text.substring(0, ix)); + } + + for (int i = ix; i < len; i++) { + char c = text.charAt(i); + if (isSpecialChar(c)) { + sb.append(String.format("%%%02X", (int)c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static int indexOfSpecialChar(String text) { + int len = text.length(); + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + if (isSpecialChar(c)) { + return i; + } + } + return -1; + } + + private static boolean isSpecialChar(char c) { + switch (c) { + case '{': + case '}': + case '%': + case '|': + return true; + } + return false; + } + + @Override + public void flush() throws IOException { + wr.flush(); + } + + @Override + public void close() throws IOException { + wr.close(); + } + } + + /** importer */ + private class RichStyledInput implements StyledInput { + private final String text; + private int index; + private StringBuilder sb; + private final ArrayList styles = new ArrayList<>(); + private int line = 1; + + public RichStyledInput(String text) { + this.text = text; + } + + @Override + public StyledSegment nextSegment() { + try { + int c = charAt(0); + switch (c) { + case -1: + return null; + case '\n': + index++; + line++; + return StyledSegment.LINE_BREAK; + case '{': + StyleAttrs a = parseAttributes(true); + if (a != null) { + if (a.isEmpty()) { + a = null; + } + return StyledSegment.ofParagraphAttributes(a); + } else { + a = parseAttributes(false); + String text = decodeText(); + return StyledSegment.of(text, a); + } + } + String text = decodeText(); + return StyledSegment.of(text); + } catch (IOException e) { + err(e); + return null; + } + } + + @Override + public void close() throws IOException { + } + + private StyleAttrs parseAttributes(boolean forParagraph) throws IOException { + StyleAttrs.Builder b = null; + for (;;) { + int c = charAt(0); + if (c != '{') { + break; + } + c = charAt(1); + if (forParagraph) { + if (c == '!') { + index++; + } else { + break; + } + } else { + if (c == '!') { + throw err("unexpected paragraph attribute"); + } + } + index++; + + int ix = text.indexOf('}', index); + if (ix < 0) { + throw err("missing }"); + } + String s = text.substring(index, ix); + if (s.length() == 0) { + if (forParagraph) { + index = ix + 1; + // special token clears paragraph attributes + return StyleAttrs.EMPTY; + } else { + throw err("empty attribute name"); + } + } + int n = parseStyleNumber(s); + if (n < 0) { + // parse the attribute + String name; + String args; + int j = s.indexOf('|'); + if (j < 0) { + name = s; + args = null; + } else { + name = s.substring(0, j); + args = s.substring(j + 1); + } + + Handler h = handlers.get(name); + if (h == null) { + // silently ignore the attribute + log("ignoring attribute: " + name); + } else { + Object v = h.read(args); + StyleAttribute a = h.getStyleAttribute(); + if (a.isParagraphAttribute() != forParagraph) { + throw err("paragraph type mismatch"); + } + if (b == null) { + b = StyleAttrs.builder(); + } + b.set(a, v); + } + index = ix + 1; + } else { + index = ix + 1; + // get style from cache + return styles.get(n); + } + } + if (b == null) { + return null; + } + StyleAttrs attrs = b.build(); + styles.add(attrs); + return attrs; + } + + private int charAt(int delta) { + int ix = index + delta; + if (ix >= text.length()) { + return -1; + } + return text.charAt(ix); + } + + private String decodeText() throws IOException { + int start = index; + for(;;) { + int c = charAt(0); + switch(c) { + case '\n': + case '{': + case -1: + return text.substring(start, index); + case '%': + return decodeText(start, index); + } + index++; + } + } + + private String decodeText(int start, int ix) throws IOException { + if (sb == null) { + sb = new StringBuilder(); + } + if (ix > start) { + sb.append(text, start, ix); + } + for (;;) { + int c = charAt(0); + switch (c) { + case '\n': + case '{': + case -1: + String s = sb.toString(); + sb.setLength(0); + return s; + case '%': + index++; + int ch = decodeHexByte(); + sb.append((char)ch); + break; + } + index++; + } + } + + private int decodeHexByte() throws IOException { + int ch = decodeHex(charAt(0)) << 4; + index++; + ch += decodeHex(charAt(0)); + return ch; + } + + private static int decodeHex(int ch) throws IOException { + int c = ch - '0'; // 0...9 + if ((c >= 0) && (c <= 9)) { + return c; + } + c = ch - 55; // handle A...F + if ((c >= 10) && (c <= 15)) { + return c; + } + c = ch - 97; // handle a...f + if ((c >= 10) && (c <= 15)) { + return c; + } + throw new IOException("not a hex char:" + ch); + } + + private int parseStyleNumber(String s) throws IOException { + if (Character.isDigit(s.charAt(0))) { + int n; + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + throw err("invalid style number " + s); + } + } + return -1; + } + + private IOException err(Object text) { + return new IOException("malformed input: " + text + ", line " + line); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RtfFormatHandler.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RtfFormatHandler.java new file mode 100644 index 00000000000..f47fb4e7a70 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/RtfFormatHandler.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import javafx.scene.input.DataFormat; +import com.sun.jfx.incubator.scene.control.rich.RtfStyledOutput; +import com.sun.jfx.incubator.scene.control.rich.rtf.RTFReader; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * This {@link DataFormatHandler} provides export/import support for RTF format. + */ +// TODO import is not yet working... +public class RtfFormatHandler extends DataFormatHandler { + /** The constructor */ + public RtfFormatHandler() { + super(DataFormat.RTF); + } + + @Override + public StyledInput createStyledInput(String text, StyleAttrs attr) throws IOException { + try (RTFReader rd = new RTFReader(text)) { + return rd.generateStyledInput(); + } + } + + @Override + public Object copy(StyledTextModel model, StyleResolver resolver, TextPos start, TextPos end) throws IOException { + StringWriter wr = new StringWriter(65536); + export(model, resolver, start, end, wr); + return wr.toString(); + } + + @Override + public void save(StyledTextModel model, StyleResolver resolver, TextPos start, TextPos end, OutputStream out) + throws IOException { + Charset ascii = Charset.forName("ASCII"); + OutputStreamWriter wr = new OutputStreamWriter(out, ascii); + export(model, resolver, start, end, wr); + } + + private void export(StyledTextModel model, StyleResolver resolver, TextPos start, TextPos end, Writer wr) + throws IOException { + + RtfStyledOutput out = new RtfStyledOutput(resolver, wr); + // collect styles + model.export(start, end, out.firstPassBuilder()); + + out.writePrologue(); + model.export(start, end, out); + out.writeEpilogue(); + out.flush(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/SegmentBuffer.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/SegmentBuffer.java new file mode 100644 index 00000000000..febac0b9afa --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/SegmentBuffer.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.IOException; +import java.util.ArrayList; + +/** + * In-memory buffer which stored {@code StyledSegment}s with associated output and input streams, + * for the use in export/import or transfer operations. + * This class and its streams are not thread safe. + */ +public class SegmentBuffer { + private ArrayList segments; + private Output output; + + /** + * Creates the buffer with the specified initial capacity. + * @param initialCapacity the initial capacity + */ + public SegmentBuffer(int initialCapacity) { + segments = new ArrayList<>(initialCapacity); + } + + /** + * Creates the buffer. + */ + public SegmentBuffer() { + this(256); + } + + /** + * Returns the singleton {@code StyledOutput} instance associated with this buffer. + * @return the StyledOutput instance + */ + public StyledOutput getStyledOutput() { + if(output == null) { + output = new Output(); + } + return output; + } + + /** + * Returns an array of {@code StyledSegment}s accumulated so far. + * @return the array of {@code StyledSegment}s + */ + public StyledSegment[] getSegments() { + return segments.toArray(new StyledSegment[segments.size()]); + } + + /** + * Returns a new instance of {@code StyledInput} which contains the segments accumulated so far. + * @return the instance of {@code StyledInput} + */ + public StyledInput getStyledInput() { + return new Input(getSegments()); + } + + private class Output implements StyledOutput { + Output() { + } + + @Override + public void append(StyledSegment s) throws IOException { + segments.add(s); + } + + @Override + public void flush() throws IOException { + } + + @Override + public void close() throws IOException { + // possibly create a boolean flag to force an IOException in append when closed + } + } + + private static class Input implements StyledInput { + private final StyledSegment[] segments; + private int index; + + Input(StyledSegment[] segments) { + this.segments = segments; + } + + @Override + public StyledSegment nextSegment() { + if (index < segments.length) { + return segments[index++]; + } + return null; + } + + @Override + public void close() throws IOException { + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/SimpleViewOnlyStyledModel.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/SimpleViewOnlyStyledModel.java new file mode 100644 index 00000000000..73ccecbb9a2 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/SimpleViewOnlyStyledModel.java @@ -0,0 +1,518 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich.model; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.scene.Node; +import javafx.scene.image.Image; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import com.sun.jfx.incubator.scene.control.rich.TextCell; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * A simple, view-only, in-memory, styled text model. + */ +public class SimpleViewOnlyStyledModel extends StyledTextModelViewOnlyBase { + private final ArrayList paragraphs = new ArrayList<>(); + + /** + * The constructor. + */ + public SimpleViewOnlyStyledModel() { + } + + /** + * Creates the model from the supplied text string by breaking it down into individual text segments. + * @param text the input multi-line text + * @return the new instance + * @throws IOException if an I/O error occurs + */ + public static SimpleViewOnlyStyledModel from(String text) throws IOException { + SimpleViewOnlyStyledModel m = new SimpleViewOnlyStyledModel(); + BufferedReader rd = new BufferedReader(new StringReader(text)); + String s; + while ((s = rd.readLine()) != null) { + m.addSegment(s); + m.nl(); + } + return m; + } + + @Override + public int size() { + return paragraphs.size(); + } + + @Override + public String getPlainText(int index) { + return paragraphs.get(index).getPlainText(); + } + + @Override + public RichParagraph getParagraph(int index) { + return paragraphs.get(index).toRichParagraph(); + } + + /** + * Appends a text segment to the last paragraph. + * The {@code text} cannot contain newline (\n) symbols. + * + * @param text the text to append, must not contain \n + * @return this model instance + */ + public SimpleViewOnlyStyledModel addSegment(String text) { + return addSegment(text, StyleAttrs.EMPTY); + } + + /** + * Appends a text segment styled with either inline style or external style names (or both). + * The {@code text} cannot contain newline (\n) symbols. + * + * @param text the text to append, must not contain \n + * @param style the inline style (example {@code "-fx-fill:red;"}), or null + * @param css external style names + * @return this model instance + */ + public SimpleViewOnlyStyledModel addSegment(String text, String style, String... css) { + Paragraph p = lastParagraph(); + p.addSegment(text, style, css); + return this; + } + + /** + * Appends a text segment styled with the specified style attributes. + * @param text the text to append, must not contain control symbols other than + * TAB. + * + * @param a the style attributes + * @return this model instance + */ + public SimpleViewOnlyStyledModel addSegment(String text, StyleAttrs a) { + // TODO split into paragraphs if \n is found, or check for \n ? + Objects.requireNonNull(a); + Paragraph p = lastParagraph(); + p.addSegment(text, a); + return this; + } + + /** + * Adds a highlight of the given color to the specified range within the last paragraph. + * + * @param start the start offset + * @param length the length of the highlight + * @param c the highlight color + * @return this model instance + */ + public SimpleViewOnlyStyledModel highlight(int start, int length, Color c) { + Paragraph p = lastParagraph(); + p.addHighlight(start, length, c); + return this; + } + + /** + * Adds a squiggly line (typically used as a spell checker indicator) to the specified range within the last paragraph. + * + * @param start the start offset + * @param length the length of the highlight + * @param c the highlight color + * @return this model instance + */ + public SimpleViewOnlyStyledModel squiggly(int start, int length, Color c) { + Paragraph p = lastParagraph(); + p.addSquiggly(start, length, c); + return this; + } + + private Paragraph lastParagraph() { + int sz = paragraphs.size(); + if (sz == 0) { + Paragraph p = new Paragraph(); + paragraphs.add(p); + return p; + } + return paragraphs.get(sz - 1); + } + + /** + * Adds a paragraph containing an image. The image will be reduced in size as necessary to fit into the available + * area if {@code wrapText} property is set. + * This method does not close the input stream. + * + * @param in the input stream providing the image. + * @return this model instance + */ + public SimpleViewOnlyStyledModel addImage(InputStream in) { + Image im = new Image(in); + Paragraph p = Paragraph.of(() -> { + return new ImageCellPane(im); + }); + paragraphs.add(p); + return this; + } + + /** + * Adds a paragraph containing a {@code Region}. + *

+ * The supplied generator must not cache or keep reference to the created {@code Region}, + * but the created {@code Region} can keep a reference to the model or a property therein. + *

+ * For example, a bidirectional binding between an inline control and some property in the model + * would synchronize the model with all the views that use it. + * + * @param generator the supplier of the paragraph content + * @return this model instance + */ + public SimpleViewOnlyStyledModel addParagraph(Supplier generator) { + Paragraph p = Paragraph.of(() -> { + return generator.get(); + }); + paragraphs.add(p); + return this; + } + + /** + * Adds an inline Node to the last paragraph. + *

+ * The supplied generator must not cache or keep reference to the created {@code Node}, + * but the created {@code Node} can keep a reference to the model or a property therein. + *

+ * For example, a bidirectional binding between an inline control and some property in the model + * would synchronize the model with all the views that use it. + * + * @param generator the supplier of the embedded Node + * @return this model instance + */ + public SimpleViewOnlyStyledModel addNodeSegment(Supplier generator) { + Paragraph p = lastParagraph(); + p.addInlineNode(generator); + return this; + } + + /** + * Adds a new paragraph (as if inserting a newline symbol into the text). + * This convenience method invokes {@link #nl(int)} with a value of 1. + * @return this model instance + */ + public SimpleViewOnlyStyledModel nl() { + return nl(1); + } + + /** + * Adds {@code n} new paragraphs (as if inserting a newline symbol into the text {@code n} times). + * + * @param count the number of paragraphs to append + * @return this model instance + */ + public SimpleViewOnlyStyledModel nl(int count) { + for (int i = 0; i < count; i++) { + int ix = paragraphs.size(); + paragraphs.add(new Paragraph()); + } + return this; + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver r, TextPos pos) { + int index = pos.index(); + if (index < paragraphs.size()) { + int off = pos.offset(); + Paragraph par = paragraphs.get(index); + StyleAttrs pa = par.getParagraphAttributes(); + StyleAttrs a = par.getStyleAttrs(r, off); + if (pa == null) { + return a; + } else { + return pa.combine(a); + } + } + return StyleAttrs.EMPTY; + } + + /** + * Sets the last paragraph's attributes. + * + * @param a the paragraph attributes + * @return this model instance + */ + public SimpleViewOnlyStyledModel setParagraphAttributes(StyleAttrs a) { + Paragraph p = lastParagraph(); + p.setParagraphAttributes(a); + return this; + } + + /** Encapsulates a paragraph */ + static class Paragraph { + private ArrayList segments; + private ArrayList> highlights; + private StyleAttrs paragraphAttributes; + + public Paragraph() { + } + + public static Paragraph of(Supplier paragraphGenerator) { + return new Paragraph() { + @Override + public final Supplier getParagraphRegion() { + return paragraphGenerator; + } + + @Override + public final String getPlainText() { + return ""; + } + + @Override + public void export(int start, int end, StyledOutput out) throws IOException { + StyledSegment seg = StyledSegment.ofRegion(paragraphGenerator); + out.append(seg); + } + }; + } + + public Supplier getParagraphRegion() { + return null; + } + + String getPlainText() { + if (segments == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (StyledSegment seg : segments) { + sb.append(seg.getText()); + } + return sb.toString(); + } + + /** + * Adds a text segment with no styling (i.e. using default style). + * + * @param text segment text + */ + void addSegment(String text) { + StyledSegment seg = StyledSegment.of(text); + segments().add(seg); + } + + /** + * Adds a styled text segment. + * + * @param text non-null text string + * @param style direct style (such as {@code -fx-fill:red;}), or null + * @param css array of style names, or null + */ + void addSegment(String text, String style, String[] css) { + StyleAttrs a = StyleAttrs.fromStyles(style, css); + addSegment(text, a); + } + + /** + * Adds a styled text segment. + * @param text the non-null text string + * @param attrs the styled attributes + */ + void addSegment(String text, StyleAttrs attrs) { + StyledSegment seg = StyledSegment.of(text, attrs); + segments().add(seg); + } + + /** + * Adds a styled text segment. + * @param text the source non-null string + * @param start the start offset of the input string + * @param end the end offset of the input string + * @param attrs the styled attributes + */ + void addSegment(String text, int start, int end, StyleAttrs attrs) { + String s = text.substring(start, end); + addSegment(s, attrs); + } + + /** + * Adds a color background highlight. + * Use translucent colors to enable multiple highlights in the same region of text. + * @param start the start offset + * @param length the end offset + * @param color the background color + */ + void addHighlight(int start, int length, Color color) { + int end = start + length; + highlights().add((cell) -> { + cell.addHighlight(start, end, color); + }); + } + + /** + * Adds a squiggly line (as seen in a spell checker) with the given color. + * @param start the start offset + * @param length the end offset + * @param color the background color + */ + void addSquiggly(int start, int length, Color color) { + int end = start + length; + highlights().add((cell) -> { + cell.addSquiggly(start, end, color); + }); + } + + private List> highlights() { + if (highlights == null) { + highlights = new ArrayList<>(4); + } + return highlights; + } + + /** + * Adds an inline node. + *

+ * The supplied generator must not cache or keep reference to the created Node, + * but the created Node can keep a reference to the model or some property therein. + *

+ * For example, a bidirectional binding between an inline control and some property in the model + * would synchronize the model with all the views that use it. + * @param generator the generator that provides the actual {@code Node} + */ + void addInlineNode(Supplier generator) { + StyledSegment seg = StyledSegment.ofInlineNode(generator); + segments().add(seg); + } + + private List segments() { + if (segments == null) { + segments = new ArrayList<>(8); + } + return segments; + } + + private List getSegments() { + return segments; + } + + private int size() { + return segments == null ? 0 : segments.size(); + } + + // for use by StyledTextModel + void export(int start, int end, StyledOutput out) throws IOException { + if (segments == null) { + out.append(StyledSegment.of("")); + } else { + int off = 0; + int sz = size(); + for (int i = 0; i < sz; i++) { + StyledSegment seg = segments.get(i); + String text = seg.getText(); + int len = (text == null ? 0 : text.length()); + if (start <= (off + len)) { + int ix0 = Math.max(0, start - off); + int ix1 = Math.min(len, end - off); + if (ix1 > ix0) { + StyledSegment ss = seg.subSegment(ix0, ix1); + out.append(ss); + } + } + off += len; + if (off >= end) { + return; + } + } + } + } + + /** + * Sets the paragraph attributes. + * @param a the paragraph attributes + */ + void setParagraphAttributes(StyleAttrs a) { + paragraphAttributes = a; + } + + /** + * Returns the paragraph attributes. + * @return the paragraph attributes, can be null + */ + StyleAttrs getParagraphAttributes() { + return paragraphAttributes; + } + + // for use by SimpleReadOnlyStyledModel + StyleAttrs getStyleAttrs(StyleResolver resolver, int offset) { + int off = 0; + int ct = size(); + for (int i = 0; i < ct; i++) { + StyledSegment seg = segments.get(i); + int len = seg.getTextLength(); + if (offset < (off + len) || (i == ct - 1)) { + return seg.getStyleAttrs(resolver); + } + off += len; + } + return StyleAttrs.EMPTY; + } + + public RichParagraph toRichParagraph() { + return new RichParagraph() { + @Override + public final String getPlainText() { + return Paragraph.this.getPlainText(); + } + + @Override + public final StyleAttrs getParagraphAttributes() { + return paragraphAttributes; + } + + @Override + final List getSegments() { + return Paragraph.this.getSegments(); + } + + @Override + public final Supplier getParagraphRegion() { + return Paragraph.this.getParagraphRegion(); + } + + @Override + final List> getHighlights() { + return highlights; + } + }; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyleAttribute.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyleAttribute.java new file mode 100644 index 00000000000..9cbcab698a4 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyleAttribute.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +/** + * Style Attribute provides a way to specify style in the RichTextArea. + * @param the attribute value type + */ +public final class StyleAttribute { + private final String name; + private final Class type; + private final boolean isParagraph; + + /** + * Constructs the style attribute. + * + * @param name the attribute name + * @param type the attribute type + * @param isParagraph specifies a paragraph attribute (true), or a character attribute (false) + */ + public StyleAttribute(String name, Class type, boolean isParagraph) { + this.name = name; + this.type = type; + this.isParagraph = isParagraph; + } + + /** + * Attribute name. + * + * @return attribute name + */ + public String getName() { + return name; + } + + /** + * Returns the class corresponding to the attribute value. + * The value must be Serializable. + * + * @return attribute type + */ + public final Class getType() { + return type; + } + + /** + * Returns true for a paragraph attribute, false for a character attribute. + * + * @return true for a paragraph attribute, false for a character attribute + */ + public boolean isParagraphAttribute() { + return isParagraph; + } + + @Override + public String toString() { + return name; + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyleAttrs.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyleAttrs.java new file mode 100644 index 00000000000..57e2f21daec --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyleAttrs.java @@ -0,0 +1,726 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; +import com.sun.jfx.incubator.scene.control.rich.CssStyles; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; + +/** + * This immutable object contains {@link StyleAttribute}s. + */ +// TODO name: StyleSet? (though it's not a set) SAttributes? (too long) +public final class StyleAttrs { + + /** Paragraph background color attribute. */ + public static final StyleAttribute BACKGROUND = new StyleAttribute<>("BACKGROUND", Color.class, true); + + /** Bullet point paragraph attribute. */ + public static final StyleAttribute BULLET = new StyleAttribute<>("BULLET", String.class, true); + + /** Bold typeface text attribute. */ + public static final StyleAttribute BOLD = new StyleAttribute<>("BOLD", Boolean.class, false); + + /** First line indent paragraph attribute, in pixels. */ + public static final StyleAttribute FIRST_LINE_INDENT = new StyleAttribute<>("FIRST_LINE_INDENT", Double.class, true); + + /** Font family text attribute. */ + public static final StyleAttribute FONT_FAMILY = new StyleAttribute<>("FONT_FAMILY", String.class, false); + + /** Font size text attribute, in pixels. */ + public static final StyleAttribute FONT_SIZE = new StyleAttribute<>("FONT_SIZE", Double.class, false); + + /** Italic type face text attribute. */ + public static final StyleAttribute ITALIC = new StyleAttribute<>("ITALIC", Boolean.class, false); + + /** Line spacing paragraph attribute. */ + public static final StyleAttribute LINE_SPACING = new StyleAttribute<>("LINE_SPACING", Double.class, true); + + /** Paragraph direction attribute. This attribute is considered only when text wrapping is enabled. */ + public static final StyleAttribute PARAGRAPH_DIRECTION = new StyleAttribute<>("PARAGRAPH_DIRECTION", ParagraphDirection.class, true); + + /** Space above (top padding) paragraph attribute. */ + public static final StyleAttribute SPACE_ABOVE = new StyleAttribute<>("SPACE_ABOVE", Double.class, true); + + /** Space below (bottom padding) paragraph attribute. */ + public static final StyleAttribute SPACE_BELOW = new StyleAttribute<>("SPACE_BELOW", Double.class, true); + + /** Space to the left (left padding) paragraph attribute. */ + public static final StyleAttribute SPACE_LEFT = new StyleAttribute<>("SPACE_LEFT", Double.class, true); + + /** Space to the right (right padding) paragraph attribute. */ + public static final StyleAttribute SPACE_RIGHT = new StyleAttribute<>("SPACE_RIGHT", Double.class, true); + + /** Strike-through text attribute. */ + public static final StyleAttribute STRIKE_THROUGH = new StyleAttribute<>("STRIKE_THROUGH", Boolean.class, false); + + /** Text alignment paragraph attribute. */ + public static final StyleAttribute TEXT_ALIGNMENT = new StyleAttribute<>("TEXT_ALIGNMENT", TextAlignment.class, true); + + /** Text color attribute. */ + public static final StyleAttribute TEXT_COLOR = new StyleAttribute<>("TEXT_COLOR", Color.class, false); + + /** Underline text attribute. */ + public static final StyleAttribute UNDERLINE = new StyleAttribute<>("UNDERLINE", Boolean.class, false); + + /** Empty attribute set. */ + public static final StyleAttrs EMPTY = new StyleAttrs(Collections.emptyMap()); + + private final HashMap,Object> attributes; + private transient String style; + + private StyleAttrs(Map,Object> a) { + this.attributes = new HashMap<>(a); + } + + /** + * Convenience method creates the instance with a single attribute. + * + * @param the attribute value type + * @param attribute the attribute + * @param value the attribute value + * @return the new instance + */ + public static StyleAttrs of(StyleAttribute attribute, V value) { + return new Builder().set(attribute, value).build(); + } + + /** + * This convenience method creates an instance from an inline style and a number of + * CSS style names. + * + * @param style the inline style, will not be applied when null + * @param names style names + * @return the new instance + */ + public static StyleAttrs fromStyles(String style, String... names) { + if ((style == null) && (names == null)) { + return StyleAttrs.EMPTY; + } else if (names == null) { + names = new String[0]; + } + return new Builder().set(CssStyles.CSS, new CssStyles(style, names)).build(); + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof StyleAttrs s) { + return attributes.equals(s.attributes); + } else { + return false; + } + } + + @Override + public int hashCode() { + return attributes.hashCode() + (31 * StyleAttrs.class.hashCode()); + } + + /** + * Returns {@code true} if this instance contains no attributes. + * @return true is no attributes are present + */ + public boolean isEmpty() { + return attributes.isEmpty(); + } + + /** + * Returns the attribute value, or null if no such attribute is present. + * + * @param the attribute value type + * @param a attribute + * @return attribute value or null + */ + public V get(StyleAttribute a) { + return (V)attributes.get(a); + } + + /** + * Returns the set of {@link StyleAttribute}s. + * @return attribute set + */ + public Set> getAttributes() { + return new HashSet<>(attributes.keySet()); + } + + /** + * Returns true if the attribute is present; false otherwise. + * + * @param a the attribute + * @return true if the attribute is present + */ + public boolean contains(StyleAttribute a) { + return attributes.containsKey(a); + } + + /** + * Creates a new StyleAttrs instance by first copying attributes from this instance, + * then adding (and/or overwriting) the attributes from the specified instance. + * + * @param attrs the attributes to combine + * @return the new instance combining the attributes + */ + public StyleAttrs combine(StyleAttrs attrs) { + return new Builder(). + merge(this). + merge(attrs). + build(); + } + + /** + * Returns true if the specified attribute has a boolean value of {@code Boolean.TRUE}, + * false otherwise. + * + * @param a the attribute + * @return true if the attribute value is {@code Boolean.TRUE} + */ + public boolean getBoolean(StyleAttribute a) { + Object v = attributes.get(a); + return Boolean.TRUE.equals(v); + } + + /** + * Returns the value of the specified attribute, or defaultValue if the specified attribute + * is not present. + * + * @param a the attribute + * @param defaultValue the default value + * @return the attribute value + */ + public double getDouble(StyleAttribute a, double defaultValue) { + Object v = attributes.get(a); + if (v instanceof Number n) { + return n.doubleValue(); + } + return defaultValue; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(32); + sb.append("{"); + boolean sep = false; + for (StyleAttribute a : attributes.keySet()) { + if (sep) { + sb.append(","); + } else { + sep = true; + } + Object v = get(a); + sb.append(a); + sb.append('='); + sb.append(v); + } + sb.append("}"); + return sb.toString(); + } + + /** + * This convenience method returns the value of {@link #BACKGROUND} attribute, or null. + * @return the background color attribute value + */ + public Color getBackground() { + return get(BACKGROUND); + } + + /** + * This convenience method returns the value of {@link #BULLET} attribute, or null. + * @return the bullet paragraph attribute value + */ + public String getBullet() { + return get(BULLET); + } + + /** + * This convenience method returns the value of the {@link #FIRST_LINE_INDENT} attribute. + * @return the first line indent value in points + */ + public Double getFirstLineIndent() { + return get(FIRST_LINE_INDENT); + } + + /** + * This convenience method returns the value of the {@link #FONT_SIZE} attribute. + * @return the font size + */ + public final Double getFontSize() { + return get(FONT_SIZE); + } + + /** + * This convenience method returns true if the value of the {@link #FONT_FAMILY} attribute is {@code Boolean.TRUE}, + * false otherwise. + * @return the font family name + */ + public String getFontFamily() { + return get(FONT_FAMILY); + } + + /** + * This convenience method returns the value of the {@link #LINE_SPACING} attribute, or null. + * @return the line spacing value + */ + public Double getLineSpacing() { + return get(LINE_SPACING); + } + + /** + * This convenience method returns the value of the {@link #SPACE_ABOVE} attribute, or null. + * @return the space above paragraph attribute value + */ + public Double getSpaceAbove() { + return get(SPACE_ABOVE); + } + + /** + * This convenience method returns the value of the {@link #SPACE_BELOW} attribute, or null. + * @return the space below paragraph attribute value + */ + public Double getSpaceBelow() { + return get(SPACE_BELOW); + } + + /** + * This convenience method returns the value of the {@link #SPACE_LEFT} attribute, or null. + * @return the space left paragraph attribute value + */ + public Double getSpaceLeft() { + return get(SPACE_LEFT); + } + + /** + * This convenience method returns the value of the {@link #SPACE_RIGHT} attribute, or null. + * @return the space right paragraph attribute value + */ + public Double getSpaceRight() { + return get(SPACE_RIGHT); + } + + /** + * This convenience method returns the value of {@link #TEXT_ALIGNMENT} attribute, or null. + * @return the paragraph alignment attribute value + */ + public TextAlignment getTextAlignment() { + return get(TEXT_ALIGNMENT); + } + + /** + * This convenience method returns the value of {@link #TEXT_COLOR} attribute, or null. + * @return the text color attribute value + */ + public Color getTextColor() { + return get(TEXT_COLOR); + } + + /** + * This convenience method returns true if the value of {@link #BOLD} attribute is {@code Boolean.TRUE}, + * false otherwise. + * @return the bold attribute value + */ + public boolean isBold() { + return getBoolean(BOLD); + } + + /** + * This convenience method returns true if the value of {@link #ITALIC} attribute is {@code Boolean.TRUE}, + * false otherwise. + * @return the italic attribute value + */ + public boolean isItalic() { + return getBoolean(ITALIC); + } + + /** + * This convenience method returns the value of {@link #PARAGRAPH_DIRECTION} paragraph attribute, + * or null if the value is not set. + * @return the paragraph direction attribute value, or null + */ + public ParagraphDirection getParagraphDirection() { + return get(PARAGRAPH_DIRECTION); + } + + /** + * This convenience method returns true if the value of {@link #STRIKE_THROUGH} attribute is {@code Boolean.TRUE}, + * false otherwise. + * @return the strike through attribute value + */ + public boolean isStrikeThrough() { + return getBoolean(STRIKE_THROUGH); + } + + /** + * This convenience method returns true if the value of {@link #UNDERLINE} attribute is {@code Boolean.TRUE}, + * false otherwise. + * @return the underline attribute value + */ + public boolean isUnderline() { + return getBoolean(UNDERLINE); + } + + /** + * Returns a new StyleAttrs instance which contains only character attributes, + * or null if no character attributes found. + * @return the instance + */ + public StyleAttrs getCharacterAttrs() { + return filterAttributes(false); + } + + /** + * Returns a new StyleAttrs instance which contains only paragraph attributes, + * or null if no paragraph attributes found. + * @return the instance + */ + public StyleAttrs getParagraphAttrs() { + return filterAttributes(true); + } + + // this is questionable. perhaps it's better to treat the attributes equally, + // and have the paragraph/segment logic handled by VFlow.applyStyles() + private StyleAttrs filterAttributes(boolean isParagraph) { + Builder b = null; + for (StyleAttribute a : attributes.keySet()) { + if (a.isParagraphAttribute() == isParagraph) { + if (b == null) { + b = StyleAttrs.builder(); + } + Object v = attributes.get(a); + b.setUnguarded(a, v); + } + } + return (b == null) ? null : b.build(); + } + + /** + * Creates an instance of StyleAttrs which contains character attributes found in the specified {@link Text} node. + * The following attributes will be set: + *

    + *
  • {@link #BOLD} + *
  • {@link #FONT_FAMILY} + *
  • {@link #FONT_SIZE} + *
  • {@link #ITALIC} + *
  • {@link #STRIKE_THROUGH} + *
  • {@link #TEXT_COLOR} + *
  • {@link #UNDERLINE} + *
+ * + * @param textNode the text node + * @return the StyleAttrs instance + */ + public static StyleAttrs fromTextNode(Text textNode) { + StyleAttrs.Builder b = StyleAttrs.builder(); + Font f = textNode.getFont(); + String st = f.getStyle().toLowerCase(Locale.US); + boolean bold = RichUtils.isBold(st); + boolean italic = RichUtils.isItalic(st); + + if (bold) { + b.setBold(true); + } + + if (italic) { + b.setItalic(true); + } + + if (textNode.isStrikethrough()) { + b.setStrikeThrough(true); + } + + if (textNode.isUnderline()) { + b.setUnderline(true); + } + + String family = f.getFamily(); + b.setFontFamily(family); + + double sz = f.getSize(); + if (sz != 12.0) { + b.setFontSize(sz); + } + + Paint x = textNode.getFill(); + if (x instanceof Color c) { + // we do not support gradients (although we could get the first color, for example) + b.setTextColor(c); + } + + return b.build(); + } + + /** + * Creates a new Builder instance. + * @return the new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** StyleAttrs are immutable, so a Builder is required to create a new instance */ + public static class Builder { + private final HashMap,Object> attributes = new HashMap<>(4); + + private Builder() { + } + + /** + * Creates an immutable instance of {@link StyleAttrs} with the attributes set by this Builder. + * @return the new instance + */ + public StyleAttrs build() { + return new StyleAttrs(attributes); + } + + /** + * Sets the value for the specified attribute. + * This method will throw an {@code IllegalArgumentException} if the value cannot be cast to the + * type specified by the attribute. + * + * @param the attribute value type + * @param a the attribute + * @param value the attribute value + * @return this Builder instance + */ + public Builder set(StyleAttribute a, V value) { + if (value == null) { + attributes.put(a, null); + } else if (value.getClass().isAssignableFrom(a.getType())) { + attributes.put(a, value); + } else { + throw new IllegalArgumentException(a + " requires value of type " + a.getType()); + } + return this; + } + + private Builder setUnguarded(StyleAttribute a, Object value) { + attributes.put(a, value); + return this; + } + + /** + * Merges the specified attributes with the attributes in this instance. + * The new values override any existing ones. + * @param attrs the attributes to merge, may be null + * @return this Builder instance + */ + public Builder merge(StyleAttrs attrs) { + if (attrs != null) { + for (StyleAttribute a : attrs.attributes.keySet()) { + Object v = attrs.get(a); + setUnguarded(a, v); + } + } + return this; + } + + /** + * Sets the paragraph background attribute to the specified color. + * It is recommended to specify a translucent background color in order to avoid obstructing + * the selection and the current line highlights. + * @param color the color + * @return this Builder instance + */ + public Builder setBackground(Color color) { + set(BACKGROUND, color); + return this; + } + + /** + * Sets the bold attribute. + * @param on true for bold typeface + * @return this Builder instance + */ + public Builder setBold(boolean on) { + set(BOLD, Boolean.valueOf(on)); + return this; + } + + /** + * Sets the BULLET attribute. + * @param bullet the bullet character + * @return this Builder instance + */ + public Builder setBullet(String bullet) { + set(BULLET, bullet); + return this; + } + + /** + * Sets the FIRST_LINE_INDENT attribute. + * @param size the first line indent value + * @return this Builder instance + */ + public Builder setFirstLineIndent(double size) { + set(FIRST_LINE_INDENT, size); + return this; + } + + /** + * Sets the font family attribute. + * @param name the font family name + * @return this Builder instance + */ + public Builder setFontFamily(String name) { + set(FONT_FAMILY, name); + return this; + } + + /** + * Sets the font size attribute. + * @param size the font size in points + * @return this Builder instance + */ + public Builder setFontSize(double size) { + set(FONT_SIZE, size); + return this; + } + + /** + * Sets the line spacing paragraph attribute. + * @param value the line spacing value + * @return this Builder instance + */ + public Builder setLineSpacing(double value) { + set(LINE_SPACING, value); + return this; + } + + /** + * Sets the italic attribute. + * @param on true for italic typeface + * @return this Builder instance + */ + public Builder setItalic(boolean on) { + set(ITALIC, Boolean.valueOf(on)); + return this; + } + + /** + * Sets the paragraph direction attribute. + * @param d the paragraph direction + * @return this Builder instance + */ + public Builder setRTL(ParagraphDirection d) { + set(PARAGRAPH_DIRECTION, d); + return this; + } + + /** + * Sets the space above paragraph attribute. + * This method also sets SPACE attribute to Boolean.TRUE. + * @param value the space amount + * @return this Builder instance + */ + public Builder setSpaceAbove(double value) { + set(SPACE_ABOVE, value); + return this; + } + + /** + * Sets the space below paragraph attribute. + * This method also sets SPACE attribute to Boolean.TRUE. + * @param value the space amount + * @return this Builder instance + */ + public Builder setSpaceBelow(double value) { + set(SPACE_BELOW, value); + return this; + } + + /** + * Sets the space left paragraph attribute. + * This method also sets SPACE attribute to Boolean.TRUE. + * @param value the space amount + * @return this Builder instance + */ + public Builder setSpaceLeft(double value) { + set(SPACE_LEFT, value); + return this; + } + + /** + * Sets the space right paragraph attribute. + * This method also sets SPACE attribute to Boolean.TRUE. + * @param value the space amount + * @return this Builder instance + */ + public Builder setSpaceRight(double value) { + set(SPACE_RIGHT, value); + return this; + } + + /** + * Sets the strike-through attribute. + * @param on true for strike-through typeface + * @return this Builder instance + */ + public Builder setStrikeThrough(boolean on) { + set(STRIKE_THROUGH, Boolean.valueOf(on)); + return this; + } + + /** + * Sets the text alignment attribute to the specified color. + * @param a the alignment + * @return this Builder instance + */ + public Builder setTextAlignment(TextAlignment a) { + set(TEXT_ALIGNMENT, a); + return this; + } + + /** + * Sets the text color attribute to the specified color. + * @param color the color + * @return this Builder instance + */ + public Builder setTextColor(Color color) { + set(TEXT_COLOR, color); + return this; + } + + /** + * Sets the underline attribute. + * @param on true for underline + * @return this Builder instance + */ + public Builder setUnderline(boolean on) { + set(UNDERLINE, Boolean.valueOf(on)); + return this; + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledInput.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledInput.java new file mode 100644 index 00000000000..ad8215a2c68 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledInput.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.Closeable; +import java.io.IOException; +import com.sun.jfx.incubator.scene.control.rich.StringStyledInput; + +/** + * This interface represents a source of styled text segments for the purposes of + * pasting, importing, or loading from an input stream. + */ +public interface StyledInput extends Closeable { + /** + * Returns the next segment, or null if no more segments. + * @return the next segment, or null if no more segments + */ + public abstract StyledSegment nextSegment(); + + /** An empty StyledInput. */ + public static final StyledInput EMPTY = new StyledInput() { + @Override + public StyledSegment nextSegment() { + return null; + } + + @Override + public void close() throws IOException { + } + }; + + /** + * Creates a plain text styled input with the specified style. + * + * @param text the source text + * @param attrs the source style attributes + * @return the StyledInput instance + */ + public static StyledInput of(String text, StyleAttrs attrs) { + return new StringStyledInput(text, attrs); + } + + /** + * Creates a plain text styled input with {@link StyleAttrs#EMPTY}. + * + * @param text the source text + * @return the StyledInput instance + */ + public static StyledInput of(String text) { + return new StringStyledInput(text, StyleAttrs.EMPTY); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledOutput.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledOutput.java new file mode 100644 index 00000000000..99c502786a4 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledOutput.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.io.Closeable; +import java.io.IOException; +import com.sun.jfx.incubator.scene.control.rich.StringBuilderStyledOutput; + +/** + * Class represents a consumer of styled text segments for the purposes of + * exporting, copying, or saving to an output stream. + */ +public interface StyledOutput extends Closeable { + /** + * Appends the next styled segment to the output. + * + * @param segment the segment to output + * @throws IOException when an I/O error occurs + */ + public void append(StyledSegment segment) throws IOException; + + /** + * Flushes this output stream, if any, and forces any buffered output bytes to be written out. + * @throws IOException when an I/O error occurs + */ + public void flush() throws IOException; + + /** + * Creates an instance of a plain text StyledOutput. + * @return the instance of a plain text StyledOutput + */ + public static StyledOutput forPlainText() { + return new StringBuilderStyledOutput(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledSegment.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledSegment.java new file mode 100644 index 00000000000..8bc21ec9ca5 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledSegment.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.util.function.Supplier; +import javafx.scene.Node; +import javafx.scene.layout.Region; +import jfx.incubator.scene.control.rich.StyleResolver; + +/** + * Data structure used to modify the styled text model. + *

+ * Each instance represents: + *

    + *
  1. a single text segment with direct style and/or style names + *
  2. a line break + *
  3. an inline Node + *
  4. a paragraph containing a single Region + *
  5. paragraph attributes + *
+ */ +// TODO perhaps add guarded/unguarded factory methods (of(), ofGuarded()) that check for <0x20, or specify that +// text must not include those characters. +public abstract class StyledSegment { + /** StyledSegment type */ + public enum Type { + /** Identifies a segment which contains an inline node. */ + INLINE_NODE, + /** Identifies a line break segment. */ + LINE_BREAK, + /** Identifies a segment which contains the paragraph attributes. */ + PARAGRAPH_ATTRIBUTES, + /** Identifies a segment which contains a single paragraph containing a {@code Region}. */ + REGION, + /** Identifies a text segment */ + TEXT + } + + /** + * Returns the type of this StyledSegment. + * @return the type + */ + public abstract Type getType(); + + /** + * Returns the text associated with this segment. + * Must be one character for inline nodes, must be null for node paragraphs or line breaks. + * @return the segment plain text + */ + public String getText() { return null; } + + /** + * Returns the length of text in the segment, or 0 for segments that contain no text or where + * {@link #getText()} returns null. + * @return the length in characters + */ + public int getTextLength() { return 0; } + + /** + * This method must return a non-null value for a segment of {@code INLINE_NODE} type, + * or null in any other case. + * @return code that creates a Node instance, or null + */ + public Supplier getInlineNodeGenerator() { return null; } + + /** + * This method must return a non-null value for a segment of {@code REGION} type, + * or null in any other case. + * @return code that creates a Region instance, or null + */ + public Supplier getParagraphNodeGenerator() { return null; } + + /** + * This method returns StyleAttrs (or null) for this segment. + * When the model manages style names (instead of actual attributes), an instance of {@link StyleResolver} + * may be used to convert the style names to individual attributes. + * Keep in mind that different views might have different stylesheet applied and + * resulting in a different set of attributes. + * @param resolver the style resolver to use + * @return style attributes + */ + public StyleAttrs getStyleAttrs(StyleResolver resolver) { return null; } + + /** + * Creates a sub-segment of this segment. + * @param start the start offset + * @param end the end offset + * @return the StyledSegment + */ + public abstract StyledSegment subSegment(int start, int end); + + private StyledSegment() { + } + + /** A styled segment that represents a line break */ + public static final StyledSegment LINE_BREAK = new StyledSegment() { + @Override + public Type getType() { + return Type.LINE_BREAK; + } + + @Override + public String toString() { + return "LINE_BREAK"; + } + + @Override + public StyledSegment subSegment(int start, int end) { + return this; + } + }; + + /** + * Creates a StyleSegment from a non-null plain text. + * Important: text must not contain any characters < 0x20, except for TAB. + * @param text the segment text + * @return the StyledSegment instance + */ + // TODO guarded of() ? + public static StyledSegment of(String text) { + return of(text, StyleAttrs.EMPTY); + } + + /** + * Creates a StyleSegment from a non-null plain text and style attributes. + * Important: text must not contain any characters < 0x20, except for TAB. + * + * @param text the segment text + * @param attrs the segment style attributes + * @return the StyledSegment instance + */ + // TODO guarded of() ? + // TODO check for null text? + public static StyledSegment of(String text, StyleAttrs attrs) { + return new StyledSegment() { + @Override + public Type getType() { + return Type.TEXT; + } + + @Override + public String getText() { + return text; + } + + @Override + public int getTextLength() { + return text.length(); + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver resolver) { + if ((resolver != null) && (attrs != null)) { + return resolver.resolveStyles(attrs); + } + return attrs; + } + + @Override + public StyledSegment subSegment(int start, int end) { + if ((start == 0) && (end == text.length())) { + return this; + } + return StyledSegment.of(substring(text, start, end), attrs); + } + + @Override + public String toString() { + return "StyledSegment{text=" + getText() + ", attrs=" + attrs + "}"; + } + }; + } + + /** + * Creates a StyledSegment which consists of a single inline Node. + * @param generator the code to create a Node instance + * @return the StyledSegment instance + */ + public static StyledSegment ofInlineNode(Supplier generator) { + return new StyledSegment() { + @Override + public Type getType() { + return Type.INLINE_NODE; + } + + @Override + public String getText() { + return " "; + } + + @Override + public int getTextLength() { + return 1; + } + + @Override + public Supplier getInlineNodeGenerator() { + return generator; + } + + @Override + public StyledSegment subSegment(int start, int end) { + return this; + } + }; + } + + /** + * Creates a StyledSegment for a paragraph that contains a single Region. + * @param generator the code to create a Region instance + * @return the StyledSegment instance + */ + public static StyledSegment ofRegion(Supplier generator) { + return new StyledSegment() { + @Override + public Type getType() { + return Type.REGION; + } + + @Override + public Supplier getParagraphNodeGenerator() { + return generator; + } + + @Override + public StyledSegment subSegment(int start, int end) { + return this; + } + }; + } + + /** + * Creates a StyledSegment which contains paragraph attributes only. + * @param attrs the paragraph attributes + * @return the StyledSegment instance + */ + public static StyledSegment ofParagraphAttributes(StyleAttrs attrs) { + return new StyledSegment() { + @Override + public Type getType() { + return Type.PARAGRAPH_ATTRIBUTES; + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver resolver) { + if (resolver != null) { + return resolver.resolveStyles(attrs); + } + return attrs; + } + + @Override + public StyledSegment subSegment(int start, int end) { + return this; + } + + @Override + public String toString() { + return "StyledSegment{par.attrs=" + attrs + "}"; + } + }; + } + + private static String substring(String text, int start, int end) { + int len = text.length(); + if ((start <= 0) && (end >= len)) { + return text; + } else { + return text.substring(Math.max(0, start), Math.min(end, len)); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledTextModel.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledTextModel.java new file mode 100644 index 00000000000..3798fac4fbf --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledTextModel.java @@ -0,0 +1,952 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich.model; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Supplier; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.scene.input.DataFormat; +import javafx.scene.layout.Region; +import com.sun.javafx.ModuleUtil; +import com.sun.jfx.incubator.scene.control.rich.Markers; +import com.sun.jfx.incubator.scene.control.rich.UndoableChange; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.rich.Marker; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * The base class for styled text models used by the + * {@link jfx.incubator.scene.control.rich.RichTextArea RichTextArea}. + *

+ * This class handles the following functionality with the intent + * to simplify custom models: + *

    + *
  • managing listeners + *
  • firing events + *
  • decomposing the edits into multiple operations performed on individual paragraphs + *
  • managing {@link Marker}s + *
+ * + *

Editing

+ * The model supports editing when {@link #isUserEditable()} returns {@code true}. + * Three methods participate in modification of the content: + * {@link #replace(StyleResolver, TextPos, TextPos, String, boolean)}, + * {@link #replace(StyleResolver, TextPos, TextPos, StyledInput, boolean)}, + * {@link #applyStyle(TextPos, TextPos, StyleAttrs, boolean)}. + * These methods decompose the main modification into operations with individual paragraphs + * and delegate these to subclasses. + *

+ * At the end of this process, an event is sent to all the {@link Listener}s, followed by the + * skin requesting the updated paragraphs when required. + * + *

Creating a Paragraph

+ * The model presents its content to the view(s) via immutable {@link RichParagraph}. + * There are three ways of styling: using inline {@link StyleAttrs attributes}, relying on + * style names in the application style sheet, or using direct styles. + * + *

Extending the Model

+ * The subclasses are free to choose how the data is stored, the only limitation is that the model neither + * stores nor caches any {@link javafx.scene.Node Node}s, since multiple skins might be attached to the same + * model. When required, the model may contain properties which can be bound to the Nodes created in + * {@link #getParagraph(int)}. It is the responsibility of the model to serialize and deserialize the value + * of such properties. + * + * @since 999 TODO + */ +public abstract class StyledTextModel { + /** + * Receives information about modifications of the model. + */ + public interface Listener { + /** + * Informs the listener that the model content has changed. + * @param ch the change + */ + public void onContentChange(ContentChange ch); + } + + /** + * Indicates whether the model supports editing by the user. + * + * @return true if the model supports editing by the user + */ + public abstract boolean isUserEditable(); + + /** + * Returns the number of paragraphs in the model. + * + * @return number of paragraphs + */ + public abstract int size(); + + /** + * Returns the plain text string for the specified paragraph. The returned text string cannot be null + * and must not contain any control characters other than TAB. + * The caller should never attempt to ask for a paragraph outside of the valid range. + * + * @param index the paragraph index in the range (0...{@link #size()}) + * @return the paragraph text string or null + */ + public abstract String getPlainText(int index); + + /** + * Returns a {@link RichParagraph} at the given model index. + * This method makes no guarantees that the same paragraph instance will be returned for the same model index. + * + * @param index paragraph index in the range (0...{@link #size()}) + * @return a new instance of TextCell created + */ + public abstract RichParagraph getParagraph(int index); + + /** + * This method gets called only if the model is editable. + * The caller guarantees that {@code start} precedes {@code end}. + * + * @param start the start of the range to be removed + * @param end the end of the range to be removed, expected to be greater than the start position + */ + protected abstract void removeRange(TextPos start, TextPos end); + + /** + * This method is called to insert a single styled text segment at the given position. + * + * @param index the paragraph index + * @param offset the insertion offset within the paragraph + * @param text the text to insert + * @param attrs the style attributes + * @return the number of characters inserted + */ + protected abstract int insertTextSegment(int index, int offset, String text, StyleAttrs attrs); + + /** + * Inserts a line break at the specified position. + * + * @param index the model index + * @param offset the text offset + */ + protected abstract void insertLineBreak(int index, int offset); + + /** + * Inserts a paragraph that contains a single {@link Region}. + *

+ * The model should not cache or otherwise retain references to the created {@code Region}s, + * as they might be requested multiple times during the lifetime of the model, or by different views. + *

+ * This method allows for embedding {@link javafx.scene.control.Control Control}s that handle user input. + * In this case, the model should declare necessary properties and provide bidirectional bindings between + * the properties in the model and the corresponding properties in the control, as well as handle copy, paste, + * writing to and reading from I/O streams. + * + * @param index model index + * @param generator code that will be used to create a Node instance + */ + protected abstract void insertParagraph(int index, Supplier generator); + + /** + * Replaces the paragraph styles in the specified paragraph. + * + * @param ix the paragraph index + * @param paragraphAttrs the paragraph attributes + */ + protected abstract void setParagraphStyle(int ix, StyleAttrs paragraphAttrs); + + /** + * Applies style to the specified text range within a single paragraph. + * The new attributes override any existing attributes. + * The {@code end} argument may exceed the paragraph length, in which case the outcome should be the same + * as supplying the paragraph length value. + * + * @param ix the paragraph index + * @param start the start offset + * @param end the end offset + * @param a the character attributes + * @param merge determines whether to merge with or overwrite the existing attributes + */ + protected abstract void applyStyle(int ix, int start, int end, StyleAttrs a, boolean merge); + + /** + * Returns the {@link StyleAttrs} of the first character at the specified position. + * When at the end of the document, returns the attributes of the last character. + * + * @param resolver the style resolver + * @param pos the text position + * @return the style attributes, non-null + */ + public abstract StyleAttrs getStyleAttrs(StyleResolver resolver, TextPos pos); + + /** + * Returns the set of supported attributes to be used for filtering in + * {@link #applyStyle(TextPos, TextPos, StyleAttrs, boolean)}, + * {@link #replace(StyleResolver, TextPos, TextPos, StyledInput, boolean)}, and + * {@link #replace(StyleResolver, TextPos, TextPos, String, boolean)}. + *

+ * A {@code null} value disables filtering. + * + * @return the supported attributes, or null + */ + protected Set> getSupportedAttributes() { + return null; + } + + /** stores the handler and its priority */ + private static record FHPriority(DataFormatHandler handler, int priority) implements Comparable{ + @Override + public int compareTo(FHPriority x) { + int d = x.priority - priority; + if (d == 0) { + // compare formats to guard against someone adding two different formats under the same priority + String us = handler().getDataFormat().toString(); + String them = x.handler().getDataFormat().toString(); + d = them.compareTo(us); + } + return d; + } + } + + private record FHKey(DataFormat format, boolean forExport) { } + + static { ModuleUtil.incubatorWarning(); } + + // TODO should it hold WeakReferences? + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList(); + private final HashMap handlers = new HashMap<>(2); + private final Markers markers = new Markers(); + private final UndoableChange head = UndoableChange.createHead(); + private final ReadOnlyBooleanWrapper undoable = new ReadOnlyBooleanWrapper(this, "undoable", false); + private final ReadOnlyBooleanWrapper redoable = new ReadOnlyBooleanWrapper(this, "redoable", false); + private UndoableChange undo = head; + + /** + * Constructs the instance of the model. + *

+ * This constructor registers data handlers for RTF, HTML (export only), and plain text. + */ + public StyledTextModel() { + registerDataFormatHandler(new RtfFormatHandler(), true, false, 1000); + registerDataFormatHandler(new HtmlExportFormatHandler(), true, false, 100); + registerDataFormatHandler(new PlainTextFormatHandler(), true, false, 0); + } + + /** + * Adds a {@link Listener} to this model. + * + * @param listener a non-null listener + */ + public void addChangeListener(StyledTextModel.Listener listener) { + listeners.add(listener); + } + + /** + * Removes a {@link Listener} from this model. + *

+ * This method does nothing if this listener has never been added. + * + * @param listener a non-null listener + */ + public void removeChangeListener(StyledTextModel.Listener listener) { + listeners.remove(listener); + } + + /** + * Registers a format handler for either export and/or import operations. + * Priority determines the format chosen for operations with the {@link javafx.scene.input.Clipboard} + * when input data is available in more than one supported format. + *

+ * This method is expected to be called by a StyledTextModel implementation constructor. + * + * @param h data format handler + * @param forExport true if the handler supports export operations + * @param forImport true if the handler supports import operations + * @param priority from 0 (lowest, usually plain text) to {@code Integer.MAX_VALUE} + */ + protected void registerDataFormatHandler(DataFormatHandler h, boolean forExport, boolean forImport, int priority) { + FHPriority p = new FHPriority(h, priority); + if (forExport) { + handlers.put(new FHKey(h.getDataFormat(), true), p); + } + if (forImport) { + handlers.put(new FHKey(h.getDataFormat(), false), p); + } + } + + /** + * Removes the data format handler registered previously with + * {@link #registerDataFormatHandler(DataFormatHandler, boolean, boolean, int)}. + * + * @param f the data format + * @param forExport whether to remove an export handler + * @param forImport whether to remove an import handler + */ + protected void removeDataFormatHandler(DataFormat f, boolean forExport, boolean forImport) { + if (forExport) { + handlers.remove(new FHKey(f, true)); + } + if (forImport) { + handlers.remove(new FHKey(f, false)); + } + } + + /** + * Returns an array of supported data formats for either export or import operations, + * in the order of priority - from high to low. + * @param forExport determines whether the operation is export (true) or import (false) + * @return supported formats + */ + public DataFormat[] getSupportedDataFormats(boolean forExport) { + ArrayList fs = new ArrayList<>(handlers.size()); + handlers.forEach((k, p) -> { + if (k.forExport == forExport) { + fs.add(p); + } + }); + Collections.sort(fs); + int sz = fs.size(); + DataFormat[] formats = new DataFormat[sz]; + for (int i = 0; i < sz; i++) { + formats[i] = fs.get(i).handler().getDataFormat(); + } + return formats; + } + + /** + * Returns a {@link DataFormatHandler} instance corresponding to the given {@link DataFormat}. + * This method will return {@code null} if the data format is not supported. + * + * @param format data format + * @param forExport for export (true) or for input (false) + * @return DataFormatHandler or null + */ + public DataFormatHandler getDataFormatHandler(DataFormat format, boolean forExport) { + FHKey k = new FHKey(format, forExport); + FHPriority p = handlers.get(k); + return p == null ? null : p.handler(); + } + + /** + * Fires a text modification event for the given range. + * + * @param start start of the affected range + * @param end end of the affected range + * @param charsTop number of characters added before any added paragraphs + * @param linesAdded number of paragraphs inserted + * @param charsBottom number of characters added after any inserted paragraphs + */ + protected void fireChangeEvent(TextPos start, TextPos end, int charsTop, int linesAdded, int charsBottom) { + ContentChange ch = ContentChange.ofEdit(start, end, charsTop, linesAdded, charsBottom); + markers.update(start, end, charsTop, linesAdded, charsBottom); + for (Listener li : listeners) { + li.onContentChange(ch); + } + } + + /** + * Fires a style change event for the given range. + * This event indicates that only the styling has changed, with no changes to any text positions. + * + * @param start the start position + * @param end the end position, must be greater than the start position + */ + protected void fireStyleChangeEvent(TextPos start, TextPos end) { + ContentChange ch = ContentChange.ofStyleChange(start, end); + for (Listener li : listeners) { + li.onContentChange(ch); + } + } + + /** + * Fires a style change event for the entire document. + * This event indicates that only the styling has changed, with no changes to any text positions. + */ + protected void fireStylingUpdate() { + TextPos end = getDocumentEnd(); + fireStyleChangeEvent(TextPos.ZERO, end); + } + + /** + * Returns the length of text in a paragraph at the specified index. + * + * @param ix the paragraph index in the model + * @return the length + */ + public int getTextLength(int ix) { + String s = getPlainText(ix); + return (s == null) ? 0 : s.length(); + } + + /** + * Exports the stream of {@code StyledSegment}s in the given range to the specified + * {@code StyledOutput}. + * + * @param start start of the range + * @param end end of the range + * @param out {@link StyledOutput} to receive the stream + * @throws IOException when an I/O error occurs + */ + public void export(TextPos start, TextPos end, StyledOutput out) throws IOException { + int cmp = start.compareTo(end); + if (cmp > 0) { + // make sure start < end + TextPos p = start; + start = end; + end = p; + } + + int ix0 = start.index(); + int ix1 = end.index(); + if (ix0 == ix1) { + // single line + int soff = start.offset(); + int eoff = end.offset(); + int len = getTextLength(ix0); + boolean withParAttrs = ((soff == 0) && ((eoff >= len) || (eoff < 0))); + exportParagraph(ix0, soff, eoff, withParAttrs, out); + } else { + // multi-line + boolean lineBreak = false; + for (int ix = ix0; ix <= ix1; ix++) { + if (lineBreak) { + out.append(StyledSegment.LINE_BREAK); + } else { + lineBreak = true; + } + + int off0; + int off1; + if (ix == ix0) { + off0 = start.offset(); + off1 = Integer.MAX_VALUE; + } else if (ix == ix1) { + off0 = 0; + off1 = end.offset(); + } else { + off0 = 0; + off1 = Integer.MAX_VALUE; + } + + exportParagraph(ix, off0, off1, true, out); + } + } + + out.flush(); + } + + /** + * Exports part of the paragraph as a sequence of styled segments. + * The caller guarantees that the start position precedes the end. + * The subclass may override this method to provide a more performant implementation. + * The paragraph end argument may exceed the actual length of the paragraph, in which case it + * should be treated as equal to the paragraph text length. + * + * @param index the paragraph index in the model + * @param start the start offset + * @param end the end offset (may exceed the paragraph length) + * @param withParAttrs determines whether to emit paragraph attributes + * @param out the target StyledOutput + * @throws IOException when an I/O error occurs + */ + protected void exportParagraph(int index, int start, int end, boolean withParAttrs, StyledOutput out) throws IOException { + RichParagraph par = getParagraph(index); + par.export(start, end, out); + if (withParAttrs) { + // sent last after the paragraph has been created + StyleAttrs pa = par.getParagraphAttributes(); + out.append(StyledSegment.ofParagraphAttributes(pa)); + } + } + + /** + * Returns the {@link Marker} at the specified position. + * The actual text position tracked by the marker will always be within the document boundaries. + * + * @param pos text position + * @return Marker instance + */ + public Marker getMarker(TextPos pos) { + TextPos p = clamp(pos); + return markers.getMarker(p); + } + + /** + * Returns the text position guaranteed to be within the document and paragraph limits. + * + * @param p text position, cannot be null + * @return text position + */ + public TextPos clamp(TextPos p) { + Objects.nonNull(p); + int ct = size(); + int ix = p.index(); + if (ix < 0) { + return TextPos.ZERO; + } else if (ix < ct) { + // clamp to paragraph length + int len = getTextLength(ix); + if (p.offset() < len) { + return p; + } + return new TextPos(ix, len); + } else { + if (ct == 0) { + return TextPos.ZERO; + } else { + ix = ct - 1; + int len = getTextLength(ix); + return new TextPos(ct - 1, len); + } + } + } + + /** + * Returns the text position corresponding to the end of the document. + * + * @return the text position + */ + public TextPos getDocumentEnd() { + int ix = size() - 1; + if (ix < 0) { + return TextPos.ZERO; + } else { + return getEndOfParagraphTextPos(ix); + } + } + + /** + * Returns a TextPos corresponding to the end of paragraph at the given index. + * + * @param index the paragraph index + * @return the text position + */ + public TextPos getEndOfParagraphTextPos(int index) { + int off = getTextLength(index); + int cix = off - 1; + if (cix < 0) { + return new TextPos(index, off); + } else { + return new TextPos(index, off, cix, false); + } + } + + /** + * Replaces the given range with the provided plain text. + *

+ * This is a convenience method which eventually calls + * {@link #replace(StyleResolver, TextPos, TextPos, StyledInput, boolean)} + * with the attributes provided by {@link #getStyleAttrs(StyleResolver, TextPos)} at the + * {@code start} position. + * + * @param resolver the StyleResolver to use + * @param start start text position + * @param end end text position + * @param text text string to insert + * @param allowUndo when true, creates an undo-redo entry + * @return the text position at the end of the inserted text, or null if the model is read only + */ + public TextPos replace(StyleResolver resolver, TextPos start, TextPos end, String text, boolean allowUndo) { + if (isUserEditable()) { + // TODO pick the lowest from start,end. Possibly add (end) argument to getStyleAttributes? + StyleAttrs a = getStyleAttrs(resolver, start); + StyledInput in = StyledInput.of(text, a); + return replace(resolver, start, end, in, allowUndo); + } + return null; + } + + /** + * Replaces the given range with the provided styled text input. + * When inserting plain text, the style is taken from the preceding text segment, or, if the text is being + * inserted in the beginning of the document, the style is taken from the following text segment. + *

+ * After the model applies the requested changes, an event is sent to all the registered listeners. + * + * @param resolver the StyleResolver to use, can be null + * @param start the start text position + * @param end the end text position + * @param input the input content stream + * @param allowUndo when true, creates an undo-redo entry + * @return the text position at the end of the inserted text, or null if the model is read only + */ + public TextPos replace(StyleResolver resolver, TextPos start, TextPos end, StyledInput input, boolean allowUndo) { + if (isUserEditable()) { + // TODO clamp to document boundaries + int cmp = start.compareTo(end); + if (cmp > 0) { + TextPos p = start; + start = end; + end = p; + } + + UndoableChange ch = allowUndo ? UndoableChange.create(this, start, end) : null; + + if (cmp != 0) { + removeRange(start, end); + } + + int index = start.index(); + int offset = start.offset(); + int top = 0; + int btm = 0; + + StyledSegment seg; + while ((seg = input.nextSegment()) != null) { + switch (seg.getType()) { + case LINE_BREAK: + insertLineBreak(index, offset); + index++; + offset = 0; + btm = 0; + break; + case PARAGRAPH_ATTRIBUTES: + StyleAttrs pa = seg.getStyleAttrs(resolver); + setParagraphStyle(index, pa); + break; + case REGION: + offset = 0; + btm = 0; + index++; + Supplier gen = seg.getParagraphNodeGenerator(); + insertParagraph(index, gen); + break; + case TEXT: + String text = seg.getText(); + StyleAttrs a = seg.getStyleAttrs(resolver); + if (a == null) { + a = StyleAttrs.EMPTY; + } else { + a = filterUnsupportedAttributes(a); + } + int len = insertTextSegment(index, offset, text, a); + if (index == start.index()) { + top += len; + } + offset += len; + btm += len; + break; + } + } + + int lines = index - start.index(); + if (lines == 0) { + btm = 0; + } + + fireChangeEvent(start, end, top, lines, btm); + + TextPos newEnd = new TextPos(index, offset); + if (allowUndo) { + add(ch, newEnd); + } + return newEnd; + } + return null; + } + + /** + * Applies the style attributes to the specified range in the document.

+ * Depending on {@code mergeAttributes} parameter, the attributes will either be merged with (true) or completely + * replace the existing attributes within the range. The affected range might be wider than the range specified + * when applying the paragraph attributes. + *

+ * This operation is undoable. + * + * @param start the start of text range + * @param end the end of text range + * @param attrs the style attributes to set + * @param mergeAttributes whether to merge or replace the attributes + */ + public final void applyStyle(TextPos start, TextPos end, StyleAttrs attrs, boolean mergeAttributes) { + if (isUserEditable()) { + if (start.compareTo(end) > 0) { + TextPos p = start; + start = end; + end = p; + } + + attrs = filterUnsupportedAttributes(attrs); + + TextPos evStart; + TextPos evEnd; + boolean changed; + + StyleAttrs pa = attrs.getParagraphAttrs(); + if (pa == null) { + evStart = start; + evEnd = end; + changed = false; + } else { + evStart = new TextPos(start.index(), 0, 0, true); + evEnd = getEndOfParagraphTextPos(end.index()); + changed = true; + } + + UndoableChange ch = UndoableChange.create(this, evStart, evEnd); + + if (pa != null) { + // set paragraph attributes + for (int ix = start.index(); ix <= end.index(); ix++) { + setParagraphStyle(ix, pa); + } + } + + // apply character styles + StyleAttrs ca = attrs.getCharacterAttrs(); + if (ca != null) { + int ix = start.index(); + if (ix == end.index()) { + applyStyle(ix, start.offset(), end.offset(), attrs, mergeAttributes); + } else { + applyStyle(ix, start.offset(), Integer.MAX_VALUE, attrs, mergeAttributes); + ix++; + while (ix < end.index()) { + applyStyle(ix, 0, Integer.MAX_VALUE, attrs, mergeAttributes); + ix++; + } + applyStyle(ix, 0, end.offset(), attrs, mergeAttributes); + } + changed = true; + } + + if (changed) { + fireStyleChangeEvent(evStart, evEnd); + add(ch, end); + } + } + } + + /** + * Removes unsupported attributes per {@link #getSupportedAttributes()}. + * + * @param attrs the input attributes + * @return the attributes that exclude unsupported ones + */ + private StyleAttrs filterUnsupportedAttributes(StyleAttrs attrs) { + Set> supported = getSupportedAttributes(); + if (supported == null) { + return attrs; + } + + StyleAttrs.Builder b = StyleAttrs.builder(); + for (StyleAttribute a : attrs.getAttributes()) { + if (supported.contains(a)) { + b.set(a, attrs.get(a)); + } + } + return b.build(); + } + + /** + * Clears the undo-redo stack. + */ + public void clearUndoRedo() { + undo = head; + updateUndoRedo(); + } + + /** + * Adds an {@code UndoableChange} to the undo/redo buffer. + * + * @param ch the change + * @param end the caret position after the change + */ + protected void add(UndoableChange ch, TextPos end) { + if (ch == null) { + // the undo-redo system is in inconsistent state, let's drop everything + clearUndoRedo(); + return; + } + + ch.setEndAfter(end); + ch.setPrev(undo); + undo.setNext(ch); + undo = ch; + updateUndoRedo(); + } + + /** + * Undoes the recent change, if possible, returning an array comprising [start, end] text positions + * prior to the change. + * Returns null when the undo operation is not possible. + * + * @param resolver the StyleResolver to use + * @return the [start, end] text positions prior to the change + */ + public final TextPos[] undo(StyleResolver resolver) { + if (undo != head) { + try { + undo.undo(resolver); + TextPos[] sel = undo.getSelectionBefore(); + undo = undo.getPrev(); + updateUndoRedo(); + return sel; + } catch (IOException e) { + // undo-redo is in inconsistent state, clear + clearUndoRedo(); + } + } + return null; + } + + /** + * Redoes the recent change, if possible, returning an array comprising [start, end] text positions + * prior to the change. + * Returns null when the redo operation is not possible. + * @param resolver the StyleResolver to use + * @return the [start, end] text positions prior to the change + */ + public final TextPos[] redo(StyleResolver resolver) { + if (undo.getNext() != null) { + try { + undo.getNext().redo(resolver); + TextPos[] sel = undo.getNext().getSelectionAfter(); + undo = undo.getNext(); + updateUndoRedo(); + return sel; + } catch (IOException e) { + // undo-redo is in inconsistent state, clear + clearUndoRedo(); + } + } + return null; + } + + /** + * The property describes if it's currently possible to undo the latest change of the content that was done. + * @return the read-only property + * @defaultValue false + */ + public final ReadOnlyBooleanProperty undoableProperty() { + return undoable.getReadOnlyProperty(); + } + + public final boolean isUndoable() { + return undoable.get(); + } + + private void setUndoable(boolean on) { + undoable.set(on); + } + + /** + * The property describes if it's currently possible to redo the latest change of the content that was undone. + * @return the read-only property + * @defaultValue false + */ + public final ReadOnlyBooleanProperty redoableProperty() { + return redoable.getReadOnlyProperty(); + } + + public final boolean isRedoable() { + return redoable.get(); + } + + private void setRedoable(boolean on) { + redoable.set(on); + } + + private void updateUndoRedo() { + setUndoable(undo != head); + setRedoable(undo.getNext() != null); + } + + /** for debugging */ + private String dump() { + StringBuilder sb = new StringBuilder(2048); + try { + sb.append("\n"); + TextPos end = getDocumentEnd(); + export(TextPos.ZERO, end, new StyledOutput() { + @Override + public void append(StyledSegment seg) throws IOException { + sb.append(" "); + sb.append(seg); + sb.append("\n"); + } + + @Override + public void flush() throws IOException { + } + + @Override + public void close() throws IOException { + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + return sb.toString(); + } + + /** + * Replaces the content of the model with the data read from the input stream, + * using the specified {@code DataFormat}. This operation clears the undo/redo stack. + * + * @param r the style resolver + * @param f the data format + * @param input the input stream + * @throws IOException in case of an I/O error + * @throws UnsupportedOperationException when the data format is not supported by the model + */ + public final void read(StyleResolver r, DataFormat f, InputStream input) throws IOException { + clearUndoRedo(); + TextPos end = getDocumentEnd(); + DataFormatHandler h = getDataFormatHandler(f, false); + if (h == null) { + throw new UnsupportedOperationException("format not supported: " + f); + } + String text = RichUtils.readString(input); + StyledInput in = h.createStyledInput(text, null); + replace(r, TextPos.ZERO, end, in, false); + } + + /** + * Writes the model content to the output stream using the specified {@code DataFormat}. + * + * @param r the style resolver + * @param f the data format + * @param out the output stream + * @throws IOException in case of an I/O error + * @throws UnsupportedOperationException when the data format is not supported by the model + */ + public final void write(StyleResolver r, DataFormat f, OutputStream out) throws IOException { + TextPos end = getDocumentEnd(); + DataFormatHandler h = getDataFormatHandler(f, true); + if (h == null) { + throw new UnsupportedOperationException("format not supported: " + f); + } + h.save(this, r, TextPos.ZERO, end, out); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledTextModelViewOnlyBase.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledTextModelViewOnlyBase.java new file mode 100644 index 00000000000..bc1748272a8 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/StyledTextModelViewOnlyBase.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.util.function.Supplier; +import javafx.scene.layout.Region; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * The base class for view-only {@link StyledTextModel}s. + *

+ * Models extending this class will not be user editable. + */ +public abstract class StyledTextModelViewOnlyBase extends StyledTextModel { + /** The constructor. */ + public StyledTextModelViewOnlyBase() { + registerDataFormatHandler(new RichTextFormatHandler(), true, false, 2000); + } + + @Override + public final boolean isUserEditable() { + return false; + } + + @Override + protected void removeRange(TextPos start, TextPos end) { + throw new UnsupportedOperationException(); + } + + @Override + protected int insertTextSegment(int index, int offset, String text, StyleAttrs attrs) { + throw new UnsupportedOperationException(); + } + + @Override + protected void insertLineBreak(int index, int offset) { + throw new UnsupportedOperationException(); + } + + @Override + protected void insertParagraph(int index, Supplier generator) { + throw new UnsupportedOperationException(); + } + + @Override + protected final void setParagraphStyle(int ix, StyleAttrs a) { + throw new UnsupportedOperationException(); + } + + @Override + protected final void applyStyle(int ix, int start, int end, StyleAttrs a, boolean merge) { + throw new UnsupportedOperationException(); + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/package-info.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/package-info.java new file mode 100644 index 00000000000..5ab9f6c8d65 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/model/package-info.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/** + * Provides common models for {@code RichTextArea} and support classes. + *

+ * Incubating Feature. + * Will be removed in a future release. + * + * @since 999 TODO + */ +package jfx.incubator.scene.control.rich.model; diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/package-info.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/package-info.java new file mode 100644 index 00000000000..ee5c2e68633 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/package-info.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/** + * Provides classes that represent {@code RichTextArea} Control. + * +

Examples

+

Creating a RichTextArea with a simple editable rich text model:

+
+
+    RichTextArea textArea = new RichTextArea();
+
+
+

Creating a read-only RichTextArea with rich text content:

+

+    SimpleReadOnlyStyledModel m = new SimpleReadOnlyStyledModel();
+    // add text segment using CSS style name (requires a style sheet)
+    m.addSegment("RichTextArea ", null, "HEADER");
+    // add text segment using direct style
+    m.addSegment("Demo", "-fx-font-size:200%;", null);
+    // newline
+    m.nl();
+
+    RichTextArea t = new RichTextArea(m);
+
+
+ * Incubating Feature. + * Will be removed in a future release. + * + * @since 999 TODO + */ +package jfx.incubator.scene.control.rich; diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/CodeAreaSkin.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/CodeAreaSkin.java new file mode 100644 index 00000000000..b4c68aeb989 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/CodeAreaSkin.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.skin; + +import java.util.Locale; +import javafx.scene.text.Font; +import com.sun.jfx.incubator.scene.control.rich.RichTextAreaSkinHelper; +import com.sun.jfx.incubator.scene.control.rich.util.ListenerHelper; +import com.sun.jfx.incubator.scene.control.rich.util.RichUtils; +import jfx.incubator.scene.control.rich.CellContext; +import jfx.incubator.scene.control.rich.CodeArea; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * The skin for {@link CodeArea}. + */ +public class CodeAreaSkin extends RichTextAreaSkin { + /** + * Constructs the CodeArea skin. + * @param control the CodeArea instance + */ + public CodeAreaSkin(CodeArea control) { + super(control); + + ListenerHelper lh = RichTextAreaSkinHelper.getListenerHelper(this); + lh.addInvalidationListener( + this::refreshLayout, + control.fontProperty(), + control.lineSpacingProperty(), + control.tabSizeProperty() + ); + } + + @Override + public void applyStyles(CellContext cx, StyleAttrs attrs, boolean forParagraph) { + super.applyStyles(cx, attrs, forParagraph); + + if (forParagraph) { + CodeArea control = (CodeArea)getSkinnable(); + // font + Font f = control.getFont(); + if (f != null) { + double size = f.getSize(); + String family = f.getFamily(); + String name = f.getName(); + if (RichUtils.isLogicalFont(family)) { + String lowerCaseName = name.toLowerCase(Locale.ENGLISH); + String style = RichUtils.guessFontStyle(lowerCaseName); + String weight = RichUtils.guessFontWeight(lowerCaseName); + cx.addStyle("-fx-font-family:'" + family + "';"); + cx.addStyle("-fx-font-style:" + style + ";"); + cx.addStyle("-fx-font-weight:" + weight + ";"); + } else { + cx.addStyle("-fx-font-family:'" + name + "';"); + } + cx.addStyle("-fx-font-size:" + size + ";"); + } + + // line spacing + double lineSpacing = control.getLineSpacing(); + cx.addStyle("-fx-line-spacing:" + lineSpacing + ";"); + + // tab size + double tabSize = control.getTabSize(); + cx.addStyle("-fx-tab-size:" + tabSize + ";"); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/RichTextAreaSkin.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/RichTextAreaSkin.java new file mode 100644 index 00000000000..b82530dc93d --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/RichTextAreaSkin.java @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxEditor + +package jfx.incubator.scene.control.rich.skin; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.VPos; +import javafx.scene.AccessibleAttribute; +import javafx.scene.control.ScrollBar; +import javafx.scene.control.Skin; +import javafx.scene.control.SkinBase; +import javafx.scene.input.DataFormat; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.Pane; +import javafx.scene.text.Text; +import com.sun.jfx.incubator.scene.control.rich.Params; +import com.sun.jfx.incubator.scene.control.rich.RichTextAreaBehavior; +import com.sun.jfx.incubator.scene.control.rich.RichTextAreaSkinHelper; +import com.sun.jfx.incubator.scene.control.rich.VFlow; +import com.sun.jfx.incubator.scene.control.rich.util.ListenerHelper; +import jfx.incubator.scene.control.rich.CellContext; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.StyleHandlerRegistry; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * Provides visual representation for RichTextArea. + *

+ * This skin consists of a top level Pane that manages the following children: + *

    + *
  • virtual flow Pane + *
  • horizontal scroll bar + *
  • vertical scroll bar + *
+ */ +public class RichTextAreaSkin extends SkinBase { + private final ListenerHelper listenerHelper; + private final RichTextAreaBehavior behavior; + private final Pane mainPane; + private final VFlow vflow; + private final ScrollBar vscroll; + private final ScrollBar hscroll; + + static { + RichTextAreaSkinHelper.setAccessor(new RichTextAreaSkinHelper.Accessor() { + @Override + public VFlow getVFlow(Skin skin) { + if (skin instanceof RichTextAreaSkin s) { + return s.getVFlow(); + } + return null; + } + + @Override + public ListenerHelper getListenerHelper(Skin skin) { + return ((RichTextAreaSkin)skin).listenerHelper; + } + }); + } + + /** + * Constructs the skin. + * @param control the owner + */ + public RichTextAreaSkin(RichTextArea control) { + super(control); + + this.listenerHelper = new ListenerHelper(); + + vscroll = createVScrollBar(); + vscroll.setOrientation(Orientation.VERTICAL); + vscroll.setMin(0.0); + vscroll.setMax(1.0); + vscroll.setUnitIncrement(Params.SCROLL_BARS_UNIT_INCREMENT); + vscroll.addEventFilter(ScrollEvent.ANY, (ev) -> ev.consume()); + + hscroll = createHScrollBar(); + hscroll.setOrientation(Orientation.HORIZONTAL); + hscroll.setMin(0.0); + hscroll.setMax(1.0); + hscroll.setUnitIncrement(Params.SCROLL_BARS_UNIT_INCREMENT); + hscroll.addEventFilter(ScrollEvent.ANY, (ev) -> ev.consume()); + + vflow = new VFlow(this, vscroll, hscroll); + + mainPane = new Pane(vflow, vscroll, hscroll) { + @Override + protected double computePrefHeight(double width) { + if (control.isUseContentHeight()) { + double h = vflow.prefHeight(width); + if (hscroll.isVisible()) { + h += hscroll.prefHeight(width); + } + Insets m = getInsets(); + return h + m.getTop() + m.getBottom(); + } + return super.computePrefHeight(width); + } + + @Override + protected double computePrefWidth(double height) { + if (control.isUseContentWidth()) { + double w = vflow.prefWidth(height); // or use unwrapped width + left + right? + if (vscroll.isVisible()) { + w += vscroll.prefWidth(height); + } + w += (Params.LAYOUT_FOCUS_BORDER * 2); + Insets m = getInsets(); + return w + m.getLeft() + m.getRight(); + } + return super.computePrefWidth(height); + } + + @Override + protected void layoutChildren() { + double x0 = snappedLeftInset() + snapSizeX(Params.LAYOUT_FOCUS_BORDER); + double y0 = snappedTopInset() + snapSizeY(Params.LAYOUT_FOCUS_BORDER); + double width = getWidth(); + double height = getHeight(); + + double vsbWidth = vscroll.isVisible() ? vscroll.prefWidth(height) : 0.0; + double hsbHeight = hscroll.isVisible() ? snapSizeY(hscroll.prefHeight(width)) : 0.0; + + double w; + if (control.isUseContentWidth()) { + w = vflow.getFlowWidth(); + } else { + w = snapSizeX(width - x0 - snappedRightInset() - snapSizeX(vsbWidth) - snapSizeX(Params.LAYOUT_FOCUS_BORDER)); + } + + double h; + if (control.isUseContentHeight()) { + h = vflow.prefHeight(width); + } else { + h = height - y0 - snappedBottomInset() - snapSizeY(Params.LAYOUT_FOCUS_BORDER) - hsbHeight; + } + + layoutInArea(vscroll, w, y0, vsbWidth, h + hsbHeight, -1, null, true, true, HPos.RIGHT, VPos.TOP); + layoutInArea(hscroll, x0, y0 + h, w, hsbHeight, -1, null, true, true, HPos.LEFT, VPos.BOTTOM); + layoutInArea(vflow, x0, y0, w, h, -1, null, true, true, HPos.LEFT, VPos.TOP); + } + }; + mainPane.getStyleClass().add("main-pane"); + getChildren().add(mainPane); + + behavior = new RichTextAreaBehavior(control); + + listenerHelper.addChangeListener(vflow::handleSelectionChange, control.selectionProperty()); + listenerHelper.addInvalidationListener(vflow::updateRateRestartBlink, true, control.caretBlinkPeriodProperty()); + listenerHelper.addInvalidationListener(vflow::updateCaretAndSelection, control.highlightCurrentParagraphProperty()); + listenerHelper.addInvalidationListener(vflow::handleContentPadding, true, control.contentPaddingProperty()); + listenerHelper.addInvalidationListener(vflow::handleDecoratorChange, + control.leftDecoratorProperty(), + control.rightDecoratorProperty() + ); + listenerHelper.addInvalidationListener(vflow::handleUseContentHeight, true, control.useContentHeightProperty()); + listenerHelper.addInvalidationListener(vflow::handleUseContentWidth, true, control.useContentWidthProperty()); + listenerHelper.addInvalidationListener(vflow::handleVerticalScroll, vscroll.valueProperty()); + listenerHelper.addInvalidationListener(vflow::handleHorizontalScroll, hscroll.valueProperty()); + listenerHelper.addInvalidationListener(vflow::handleWrapText, control.wrapTextProperty()); + listenerHelper.addInvalidationListener(vflow::handleModelChange, control.modelProperty()); + listenerHelper.addChangeListener(control.modelProperty(), true, this::handleModelChange); + } + + @Override + public void install() { + getSkinnable().getInputMap().setSkinInputMap(behavior.getSkinInputMap()); + } + + @Override + public void dispose() { + if (getSkinnable() != null) { + listenerHelper.disconnect(); + vflow.dispose(); + super.dispose(); + } + } + + private void handleModelChange(Object src, StyledTextModel old, StyledTextModel m) { + if (old != null) { + old.removeChangeListener(vflow); + } + + if (m != null) { + m.addChangeListener(vflow); + } + } + + /** + * Creates the vertical scroll bar. + *

+ * The subclasses may override this method to provide custom ScrollBar implementation. + * + * @return the vertical scroll bar + */ + protected ScrollBar createVScrollBar() { + return new ScrollBar(); + } + + /** + * Creates the horizontal scroll bar. + *

+ * The subclasses may override this method to provide custom ScrollBar implementation. + * + * @return the horizontal scroll bar + */ + protected ScrollBar createHScrollBar() { + return new ScrollBar(); + } + + private VFlow getVFlow() { + return vflow; + } + + /** + * Returns the skin's {@link StyleResolver}. + * @return style resolver instance + */ + public StyleResolver getStyleResolver() { + return vflow; + } + + /** + * Copies the text in the specified format when selection exists and when the export in this format + * is supported by the model, and the skin must be installed; otherwise, this method is a no-op. + * + * @param format data format + */ + public void copyText(DataFormat format) { + behavior.copy(format); + } + + /** + * Pastes the clipboard content at the caret, or, if selection exists, replacing the selected text. + * The format must be supported by the model, and the skin must be installed, + * otherwise this method has no effect. + * + * @param format data format + */ + public void pasteText(DataFormat format) { + behavior.paste(format); + } + + /** + * Applies styles based on supplied attribute set to either the whole paragraph or the text segment. + * This method can be overriden by other skin implementations to provide additional styling. + * The overriding method must call super implementation. + * + * @param context the cell context + * @param attrs the attributes + * @param forParagraph determines whether the styles are applied to the paragraph (true), or text segment (false) + */ + public void applyStyles(CellContext context, StyleAttrs attrs, boolean forParagraph) { + if (attrs != null) { + RichTextArea c = getSkinnable(); + StyleHandlerRegistry r = c.getStyleHandlerRegistry(); + for (StyleAttribute a : attrs.getAttributes()) { + Object v = attrs.get(a); + if (v != null) { + r.process(c, forParagraph, context, a, v); + } + } + } + } + + /** + * Discards any cached layout information and calls + * {@link javafx.scene.Parent#requestLayout() requestLayout()}. + */ + // TODO alternative: simply override requestLayout() ? + public void refreshLayout() { + vflow.invalidateLayout(); + getSkinnable().requestLayout(); + } + + @Override + protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + if (getSkinnable().isUseContentHeight()) { + return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset); + } + return Params.PREF_HEIGHT; + } + + @Override + protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + if (getSkinnable().isUseContentWidth()) { + return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset); + } + return Params.PREF_WIDTH; + } + + @Override + protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + return Params.MIN_HEIGHT; + } + + @Override + protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + return Params.MIN_WIDTH; + } + + @Override + protected Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { + switch (attribute) { + // TextAreaSkin delegates to its Text +// case LINE_FOR_OFFSET: + case LINE_START: + return 0; + case LINE_END: + TextPos p = getSkinnable().getCaretPosition(); + if (p != null) { + String s = getSkinnable().getPlainText(p.index()); + return s.length(); + } + return null; +// case BOUNDS_FOR_RANGE: +// case OFFSET_AT_POINT: +// Text text = getTextNode(); +// return text.queryAccessibleAttribute(attribute, parameters); + default: + return super.queryAccessibleAttribute(attribute, parameters); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/package-info.java b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/package-info.java new file mode 100644 index 00000000000..8a3ef57a316 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/jfx/incubator/scene/control/rich/skin/package-info.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/** + * Contains RichTextArea skin and related classes. + *

+ * Incubating Feature. + * Will be removed in a future release. + * + * @since 999 TODO +*/ +package jfx.incubator.scene.control.rich.skin; diff --git a/modules/jfx.incubator.richtext/src/main/java/module-info.java b/modules/jfx.incubator.richtext/src/main/java/module-info.java new file mode 100644 index 00000000000..722635d56f8 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/main/java/module-info.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/** + * RichTextArea Control (Incubator) + *

+ * Incubating Feature. + * Will be removed in a future release. + * + * @moduleGraph + * @since 999 + */ +module jfx.incubator.richtext { + requires transitive javafx.base; + requires transitive javafx.graphics; + requires transitive javafx.controls; + requires transitive jfx.incubator.input; + requires java.desktop; + + exports jfx.incubator.scene.control.rich; + exports jfx.incubator.scene.control.rich.skin; + exports jfx.incubator.scene.control.rich.model; +} diff --git a/modules/jfx.incubator.richtext/src/shims/java/jfx/incubator/scene/control/rich/RichTextAreaShim.java b/modules/jfx.incubator.richtext/src/shims/java/jfx/incubator/scene/control/rich/RichTextAreaShim.java new file mode 100644 index 00000000000..15f15e4c0c0 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/shims/java/jfx/incubator/scene/control/rich/RichTextAreaShim.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich; + +import com.sun.jfx.incubator.scene.control.rich.VFlow; + +/** + * RichTextArea shim. + */ +public class RichTextAreaShim { + /** for when we need to access VFlow */ + public static VFlow vflow(RichTextArea t) { + return t.vflow(); + } +} diff --git a/modules/jfx.incubator.richtext/src/shims/java/jfx/incubator/scene/control/rich/model/EditableRichTextModelShim.java b/modules/jfx.incubator.richtext/src/shims/java/jfx/incubator/scene/control/rich/model/EditableRichTextModelShim.java new file mode 100644 index 00000000000..9b36cac40e2 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/shims/java/jfx/incubator/scene/control/rich/model/EditableRichTextModelShim.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jfx.incubator.scene.control.rich.model; + +import java.util.List; + +public class EditableRichTextModelShim { + public static List getSegments(RichParagraph par) { + return par.getSegments(); + } +} diff --git a/modules/jfx.incubator.richtext/src/test/addExports b/modules/jfx.incubator.richtext/src/test/addExports new file mode 100644 index 00000000000..fa23f1e7876 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/test/addExports @@ -0,0 +1,20 @@ +--add-exports javafx.base/com.sun.javafx=ALL-UNNAMED +# +--add-exports javafx.graphics/com.sun.javafx.application=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.perf=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.stage=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.javafx.util=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.prism=ALL-UNNAMED +--add-exports javafx.graphics/com.sun.scenario.animation=ALL-UNNAMED +# +--add-exports javafx.controls/com.sun.javafx.scene.control.behavior=ALL-UNNAMED +--add-exports javafx.controls/com.sun.javafx.scene.control.inputmap=ALL-UNNAMED +--add-exports javafx.controls/com.sun.javafx.scene.control.skin=ALL-UNNAMED +--add-exports javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED +# +--add-exports jfx.incubator.richtext/com.sun.jfx.incubator.scene.control.dummy=ALL-UNNAMED +--add-exports jfx.incubator.richtext/com.sun.jfx.incubator.scene.control.rich=ALL-UNNAMED diff --git a/modules/jfx.incubator.richtext/src/test/java/test/com/sun/jfx/incubator/scene/control/rich/TestRichTextArea.java b/modules/jfx.incubator.richtext/src/test/java/test/com/sun/jfx/incubator/scene/control/rich/TestRichTextArea.java new file mode 100644 index 00000000000..2723bd529cb --- /dev/null +++ b/modules/jfx.incubator.richtext/src/test/java/test/com/sun/jfx/incubator/scene/control/rich/TestRichTextArea.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.com.sun.jfx.incubator.scene.control.rich; + +import org.junit.jupiter.api.Test; +import com.sun.jfx.incubator.scene.control.rich.VFlow; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.RichTextAreaShim; + +/** + * Tests RichTextArea control. + */ +public class TestRichTextArea { + /** + * Tests the shim. + */ + // TODO remove once a real test which needs the shim is added. + @Test + public void testShim() { + RichTextArea t = new RichTextArea(); + VFlow f = RichTextAreaShim.vflow(t); + } +} diff --git a/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/CodeAreaTest.java b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/CodeAreaTest.java new file mode 100644 index 00000000000..26fcbd975ee --- /dev/null +++ b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/CodeAreaTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.jfx.incubator.scene.control.rich; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import jfx.incubator.scene.control.rich.CodeArea; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; + +/** + * Tests CodeArea. + */ +public class CodeAreaTest { + @BeforeEach + public void beforeEach() { + setUncaughtExceptionHandler(); + } + + @AfterEach + public void cleanup() { + removeUncaughtExceptionHandler(); + } + + private void setUncaughtExceptionHandler() { + Thread.currentThread().setUncaughtExceptionHandler((thread, throwable) -> { + if (throwable instanceof RuntimeException) { + throw (RuntimeException)throwable; + } else { + Thread.currentThread().getThreadGroup().uncaughtException(thread, throwable); + } + }); + } + + private void removeUncaughtExceptionHandler() { + Thread.currentThread().setUncaughtExceptionHandler(null); + } + + /** can set a null and non-null CodeTextModel */ + @Test + public void nullModel() { + CodeArea t = new CodeArea(); + t.setModel(null); + t.setModel(new CodeTextModel()); + + } + + /** disallows setting model other than CodeTextModel */ + @Test + public void wrongModel() { + CodeArea t = new CodeArea(); + Assertions.assertThrows(IllegalArgumentException.class, () -> { + t.setModel(new EditableRichTextModel()); + }); + } +} diff --git a/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/EditableRichTextModelTest.java b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/EditableRichTextModelTest.java new file mode 100644 index 00000000000..c81cd410d43 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/EditableRichTextModelTest.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.jfx.incubator.scene.control.rich.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import com.sun.jfx.incubator.scene.control.rich.SegmentStyledInput; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; +import jfx.incubator.scene.control.rich.model.EditableRichTextModelShim; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledSegment; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * Tests EditableRichTextModel. + */ +public class EditableRichTextModelTest { + private static final StyleAttrs BOLD = StyleAttrs.builder().setBold(true).build(); + private static final StyleAttrs ITALIC = StyleAttrs.builder().setItalic(true).build(); + + @Test + public void insertLineBreak() { + test(List.of(p()), List.of(p(), p()), (m) -> { + m.replace(null, TextPos.ZERO, TextPos.ZERO, "\n", true); + }); + } + + @Test + public void delete() { + // delete paragraph (multiple segments), keep its attributes + test( + List.of( + p(s("aa", BOLD), s("bb", ITALIC), s("cc", BOLD)) + ), + List.of( + p("", BOLD) + ), + (m) -> { + m.replace(null, t(0, 0), t(0, 6), "", false); + } + ); + + // delete paragraph (single segment), keep its attributes + test( + List.of( + p("aa", BOLD) + ), + List.of( + p("", BOLD) + ), + (m) -> { + m.replace(null, t(0, 0), t(0, 2), "", false); + } + ); + + // delete newline and merge segments with same attributes + test( + List.of( + p("aa", BOLD), + p("bb", BOLD) + ), + List.of( + p("aabb", BOLD) + ), + (m) -> { + m.replace(null, t(0, 2), t(1, 0), "", false); + } + ); + + // delete empty paragraph, i.e. backspace from pos(2, 0) + test( + List.of( + p("aa", BOLD), + p(), + p("bb", BOLD) + ), + List.of( + p("aa", BOLD), + p("bb", BOLD) + ), + (m) -> { + m.replace(null, t(2, 0), t(1, 0), "", false); + } + ); + } + + private static RichParagraph p() { + return RichParagraph.builder().build(); + } + + private static RichParagraph p(String text, StyleAttrs a) { + return RichParagraph.builder().addSegment(text, a).build(); + } + + private static RichParagraph p(StyledSegment... segments) { + RichParagraph.Builder b = RichParagraph.builder(); + for (StyledSegment s : segments) { + b.addSegment(s.getText(), s.getStyleAttrs(null)); + } + return b.build(); + } + + private static StyledSegment s(String text, StyleAttrs a) { + return StyledSegment.of(text, a); + } + + private static TextPos t(int index, int offset) { + return new TextPos(index, offset); + } + + protected void test(List initial, List expected, Consumer op) { + EditableRichTextModel m = new EditableRichTextModel(); + + // initial state + boolean newline = false; + for (RichParagraph par : initial) { + if (newline) { + TextPos p = m.getDocumentEnd(); + m.replace(null, p, p, "\n", false); + } else { + newline = true; + } + + List ss = EditableRichTextModelShim.getSegments(par); + if (ss == null) { + ss = List.of(); + } + StyledSegment[] segments = ss.toArray(StyledSegment[]::new); + StyledInput in = new SegmentStyledInput(segments); + TextPos p = m.getDocumentEnd(); + m.replace(null, p, p, in, false); + } + + // test operation + op.accept(m); + + // compare + int sz = m.size(); + ArrayList result = new ArrayList<>(sz); + for (int i = 0; i < sz; i++) { + RichParagraph p = m.getParagraph(i); + result.add(p); + } + String err = checkEquals(expected, result); + if(err != null) { + System.err.println("Error: " + err); + System.err.println("expected=" + dump(expected)); + System.err.println("actual=" + dump(result)); + Assertions.fail(err); + } + } + + private static String dump(List ps) { + if(ps == null) { + return "null"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + for (RichParagraph p : ps) { + dump(sb, p); + } + return sb.toString(); + } + + private static void dump(StringBuilder sb, RichParagraph p) { + sb.append(" {pa=").append(p.getParagraphAttributes()); + sb.append(", segments="); + List ss = EditableRichTextModelShim.getSegments(p); + if(ss == null) { + sb.append("null"); + } else { + sb.append("\n"); + for(StyledSegment s: ss) { + sb.append(" {text=\"").append(s.getText()); + sb.append("\", a=").append(s.getStyleAttrs(null)).append("\n"); + } + } + } + + private static boolean eq(Object a, Object b) { + if (a == null) { + return b == null; + } + return a.equals(b); + } + + private static String checkEquals(List expected, List actual) { + int sz = expected.size(); + if (sz != actual.size()) { + return "expected array size=" + sz + " actual=" + actual.size(); + } + + for (int i = 0; i < sz; i++) { + RichParagraph pa = expected.get(i); + RichParagraph pb = actual.get(i); + + if (!eq(pa.getParagraphAttributes(), pb.getParagraphAttributes())) { + return "paragraph attributes at ix=" + i; + } + + List lsa = EditableRichTextModelShim.getSegments(pa); + List lsb = EditableRichTextModelShim.getSegments(pb); + if (!((lsa != null) && (lsb != null))) { + if ((lsa == null) && (lsb == null)) { + return null; + } + return "segment array mismatch at ix=" + i; + } + if (lsa != null) { + for (int j = 0; j < lsa.size(); j++) { + StyledSegment a = lsa.get(j); + StyledSegment b = lsb.get(j); + if (!eq(a.getText(), b.getText())) { + return "segment text[" + j + "] at ix=" + i; + } + if (!eq(a.getStyleAttrs(null), b.getStyleAttrs(null))) { + return "segment attrs[" + j + "] at ix=" + i; + } + } + } + } + + return null; + } +} diff --git a/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/TestEditableRichTextModel.java b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/TestEditableRichTextModel.java new file mode 100644 index 00000000000..b456a2a2bd9 --- /dev/null +++ b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/TestEditableRichTextModel.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.jfx.incubator.scene.control.rich.model; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.function.Consumer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import com.sun.jfx.incubator.scene.control.rich.RichTextFormatHandlerHelper; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; +import jfx.incubator.scene.control.rich.model.RichTextFormatHandler; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledOutput; + +/** + * Tests EditableRichTextModel handling of style attributes when editing. + * The tests use RichTextFormatHandler presentation, which may or may not be the best idea, + * but it's definitely the quickest. + */ +public class TestEditableRichTextModel { + @Test + public void testInsertLineBreak() { + // empty model + t( + null, + (m) -> { + m.replace(null, TextPos.ZERO, TextPos.ZERO, "\n", false); + }, + "{!}\n{!}" + ); + + // two newlines + t( + null, + (m) -> { + m.replace(null, TextPos.ZERO, TextPos.ZERO, "\n\n", false); + }, + "{!}\n{!}\n{!}" + ); + + // in front of 1st segment + t( + "{b}{i}0123{!}", + (m) -> { + m.replace(null, TextPos.ZERO, TextPos.ZERO, "\n", false); + }, + "{!}\n{b}{i}0123{!}" + ); + + // in the middle of segment: both parts retain styles + t( + "{b}{i}0123{!}", + (m) -> { + m.replace(null, new TextPos(0, 2), new TextPos(0, 2), "\n", false); + }, + "{b}{i}01{!}\n{0}23{!}" + ); + + // at the end of segment + t( + "{b}{i}0123{!}", + (m) -> { + m.replace(null, new TextPos(0, 4), new TextPos(0, 4), "\n", false); + }, + "{b}{i}0123{!}\n{!}" + ); + } + + @Test + public void testDeleteParagraphStart() { + t( + "{fs|24.0}{tc|808080}aaaaa: {fs|24.0}bbbbb{!}", + (m) -> { + m.replace(null, p(0, 13), p(0, 0), "", false); + }, + "{!}" + ); + } + + @Test + public void testZeroWidthSegment() { + t( + "{fs|24.0}{tc|808080}a: {fs|24.0}b{!}\n{0}c: {1}d{!}", + (m) -> { + m.replace(null, p(0, 4), p(1, 0), "", false); + }, + "{fs|24.0}{tc|808080}a: {fs|24.0}b{0}c: {1}d{!}" + ); + } + + private static TextPos p(int index, int offset) { + return new TextPos(index, offset); + } + + private void t(String initial, Consumer op, String expected) { + try { + EditableRichTextModel m = new EditableRichTextModel(); + RichTextFormatHandler h = new RichTextFormatHandler(); + + // set initial text + if (initial != null) { + StyledInput in = h.createStyledInput(initial, null); + TextPos end = m.replace(null, TextPos.ZERO, TextPos.ZERO, in, false); + // check initial text + StringWriter wr = new StringWriter(); + StyledOutput out = RichTextFormatHandlerHelper.createStyledOutput(h, null, wr); + m.export(TextPos.ZERO, end, out); + String s = wr.toString(); + Assertions.assertEquals(initial, s, "problem setting initial text"); + } + + op.accept(m); + + // check output + { + StringWriter wr = new StringWriter(); + StyledOutput out = RichTextFormatHandlerHelper.createStyledOutput(h, null, wr); + TextPos end = m.getDocumentEnd(); + m.export(TextPos.ZERO, end, out); + String s = wr.toString(); + Assertions.assertEquals(expected, s, "operation failed"); + } + } catch(IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/TestRichTextFormatHandler.java b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/TestRichTextFormatHandler.java new file mode 100644 index 00000000000..3dff9ced78f --- /dev/null +++ b/modules/jfx.incubator.richtext/src/test/java/test/jfx/incubator/scene/control/rich/model/TestRichTextFormatHandler.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.jfx.incubator.scene.control.rich.model; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import com.sun.jfx.incubator.scene.control.rich.RichTextFormatHandlerHelper; +import jfx.incubator.scene.control.rich.model.ParagraphDirection; +import jfx.incubator.scene.control.rich.model.RichTextFormatHandler; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledOutput; +import jfx.incubator.scene.control.rich.model.StyledSegment; + +/** + * Tests RichTextFormatHandler. + */ +public class TestRichTextFormatHandler { + private static final boolean DEBUG = true; + + @Test + public void testRoundTrip() throws IOException { + Object[] ss = { + List.of( + p( + a(StyleAttrs.BACKGROUND, Color.RED), + a(StyleAttrs.BULLET, "⌘"), + a(StyleAttrs.FIRST_LINE_INDENT, 10.0), + a(StyleAttrs.LINE_SPACING, 11.0), + a(StyleAttrs.PARAGRAPH_DIRECTION, ParagraphDirection.RIGHT_TO_LEFT) + ), + s("bold", StyleAttrs.BOLD), + s("font family", a(StyleAttrs.FONT_FAMILY, "Arial")), + s("font size", a(StyleAttrs.FONT_SIZE, 12.0)), + s("italic", StyleAttrs.ITALIC), + nl(), + + p( + a(StyleAttrs.SPACE_ABOVE, 13.0), + a(StyleAttrs.SPACE_BELOW, 14.0), + a(StyleAttrs.SPACE_LEFT, 15.0), + a(StyleAttrs.SPACE_RIGHT, 16.0), + a(StyleAttrs.TEXT_ALIGNMENT, TextAlignment.CENTER), + a(StyleAttrs.PARAGRAPH_DIRECTION, ParagraphDirection.LEFT_TO_RIGHT) + ), + s("strike through", StyleAttrs.STRIKE_THROUGH), + s("text color", a(StyleAttrs.TEXT_COLOR, Color.GREEN)), + s("underline", StyleAttrs.UNDERLINE), + nl(), + + s("combined", StyleAttrs.ITALIC, a(StyleAttrs.TEXT_COLOR, Color.RED), StyleAttrs.UNDERLINE), + nl() + + // TODO test escapes in text, attribute names, attribute values + ) + }; + + RichTextFormatHandler handler = new RichTextFormatHandler(); + + for (Object x : ss) { + testRoundTrip(handler, (List)x); + } + } + + @Test + public void testStyleDeduplication() throws IOException { + StyledSegment[] input = { + s("0", StyleAttrs.BOLD), + s("1", StyleAttrs.ITALIC), + s("2", StyleAttrs.BOLD), + s("3", StyleAttrs.ITALIC) + }; + + StringWriter wr = new StringWriter(); + StyledOutput out = RichTextFormatHandlerHelper.createStyledOutput(new RichTextFormatHandler(), null, wr); + for (StyledSegment s : input) { + out.append(s); + } + out.flush(); + String s = wr.toString(); + Assertions.assertTrue(s.indexOf("{0}") > 0); + Assertions.assertTrue(s.indexOf("{1}") > 0); + } + + @Test + public void testEscapes() throws IOException { + StringWriter wr = new StringWriter(); + StyledOutput out = RichTextFormatHandlerHelper.createStyledOutput(new RichTextFormatHandler(), null, wr); + out.append(StyledSegment.of("{|%}")); + out.flush(); + String s = wr.toString(); + String expected = "%7B%7C%25%7D"; + Assertions.assertEquals(expected, s); + } + + // creates a segment with paragraph attributes + private static StyledSegment p(Object... items) { + StyleAttrs.Builder b = StyleAttrs.builder(); + for (Object x : items) { + if (x instanceof StyleAttribute a) { + b.set(a, Boolean.TRUE); + } else if (x instanceof StyleAttrs a) { + b.merge(a); + } else { + throw new Error("?" + x); + } + } + StyleAttrs attrs = b.build(); + checkParagraphType(attrs, true); + return StyledSegment.ofParagraphAttributes(attrs); + } + + // creates a text segment + private static StyledSegment s(String text, Object... items) { + StyleAttrs.Builder b = StyleAttrs.builder(); + for (Object x : items) { + if (x instanceof StyleAttribute a) { + b.set(a, Boolean.TRUE); + } else if (x instanceof StyleAttrs a) { + b.merge(a); + } else { + throw new Error("?" + x); + } + } + StyleAttrs attrs = b.build(); + checkParagraphType(attrs, false); + return StyledSegment.of(text, attrs); + } + + private static void checkParagraphType(StyleAttrs attrs, boolean forParagraph) { + for (StyleAttribute a : attrs.getAttributes()) { + Assertions.assertEquals(forParagraph, a.isParagraphAttribute(), "wrong isParagraph: " + a); + } + } + + private static StyleAttrs a(StyleAttribute a, T value) { + return StyleAttrs.builder().set(a, value).build(); + } + + private static StyledSegment nl() { + return StyledSegment.LINE_BREAK; + } + + private void testRoundTrip(RichTextFormatHandler handler, List input) throws IOException { + // export to string + int ct = 0; + StringWriter wr = new StringWriter(); + StyledOutput out = RichTextFormatHandlerHelper.createStyledOutput(handler, null, wr); + for (StyledSegment s : input) { + if (DEBUG) { + System.out.println(s); + } + out.append(s); + ct++; + } + out.flush(); + String exported = wr.toString(); + if (DEBUG) { + System.out.println("exported " + ct + " segments=" + exported); + } + + // import from string + ArrayList segments = new ArrayList<>(); + StyledInput in = handler.createStyledInput(exported, null); + StyledSegment seg; + while ((seg = in.nextSegment()) != null) { + if (DEBUG) { + System.out.println(seg); + } + segments.add(seg); + } + + // check segments for equality + Assertions.assertEquals(input.size(), segments.size()); + for (int i = 0; i < input.size(); i++) { + StyledSegment is = input.get(i); + StyledSegment rs = segments.get(i); + Assertions.assertEquals(is.getType(), rs.getType()); + Assertions.assertEquals(is.getText(), rs.getText()); + Assertions.assertEquals(is.getStyleAttrs(null), rs.getStyleAttrs(null)); + } + + // export to a string again + wr = new StringWriter(); + out = RichTextFormatHandlerHelper.createStyledOutput(handler, null, wr); + for (StyledSegment s : segments) { + out.append(s); + } + out.flush(); + String result = wr.toString(); + if (DEBUG) { + System.out.println("result=" + result); + } + + // relying on stable order of attributes + Assertions.assertEquals(exported, result); + } + + private void testRoundTrip_DELETE(RichTextFormatHandler handler, String text) throws IOException { + ArrayList segments = new ArrayList<>(); + + StyledInput in = handler.createStyledInput(text, null); + StyledSegment seg; + while ((seg = in.nextSegment()) != null) { + segments.add(seg); + if (DEBUG) { + System.out.println(seg); + } + } + + StringWriter wr = new StringWriter(); + StyledOutput out = RichTextFormatHandlerHelper.createStyledOutput(handler, null, wr); + for (StyledSegment s : segments) { + out.append(s); + } + out.flush(); + + String result = wr.toString(); + Assertions.assertEquals(text, result); + } +} diff --git a/settings.gradle b/settings.gradle index e372c0e48e9..f91c6a478a9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,11 +23,13 @@ * questions. */ -include "base", "graphics", "controls", "swing", "swt", "fxml", "web", "media", "systemTests" +include "base", "graphics", "controls", "incubator.input", "incubator.richtext", "swing", "swt", "fxml", "web", "media", "systemTests" project(":base").projectDir = file("modules/javafx.base") project(":graphics").projectDir = file("modules/javafx.graphics") project(":controls").projectDir = file("modules/javafx.controls") +project(":incubator.input").projectDir = file("modules/jfx.incubator.input") +project(":incubator.richtext").projectDir = file("modules/jfx.incubator.richtext") project(":swing").projectDir = file("modules/javafx.swing") project(":swt").projectDir = file("modules/javafx.swt") project(":fxml").projectDir = file("modules/javafx.fxml") diff --git a/tests/manual/RichTextAreaDemo/.classpath b/tests/manual/RichTextAreaDemo/.classpath new file mode 100644 index 00000000000..713e0b6f9e3 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/.classpath @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/manual/RichTextAreaDemo/.project b/tests/manual/RichTextAreaDemo/.project new file mode 100644 index 00000000000..701660e8813 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/.project @@ -0,0 +1,17 @@ + + + RichTextAreaDemo + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/tests/manual/RichTextAreaDemo/.settings/org.eclipse.core.resources.prefs b/tests/manual/RichTextAreaDemo/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 00000000000..99f26c0203a --- /dev/null +++ b/tests/manual/RichTextAreaDemo/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/tests/manual/RichTextAreaDemo/README.md b/tests/manual/RichTextAreaDemo/README.md new file mode 100644 index 00000000000..aaec2c9da2b --- /dev/null +++ b/tests/manual/RichTextAreaDemo/README.md @@ -0,0 +1,24 @@ +# Rich Text Area Demos + +This project contains a number of applications that use the new RichTextArea and CodeArea controls, +for the purposes of demonstration of capabilities as well as testing. + + +## Rich Editor Application + +[RichEditorDemoApp.java](src/com/oracle/demo/rich/editor/RichEditorDemoApp.java) +is an example of a simple standalone rich text editor that uses the new RichTextArea control. + + + +## RichTextArea Tester + +[RichTextAreaDemoApp.java](src/com/oracle/demo/rich/rta/RichTextAreaDemoApp.java) +provides a demo application primarily for testing of the RichTextArea behavior. + + + +## CodeArea Tester + +[CodeAreaDemoApp.java](src/com/oracle/demo/rich/codearea/CodeAreaDemoApp.java) +provides a demo application primarily for testing of the CodeArea behavior. diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaDemoApp.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaDemoApp.java new file mode 100644 index 00000000000..1af522cb70e --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaDemoApp.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.codearea; + +import javafx.application.Application; +import javafx.stage.Stage; +import com.oracle.demo.rich.settings.FxSettings; + +/** + * CodeArea Demo Application. + */ +public class CodeAreaDemoApp extends Application { + public static void main(String[] args) { + Application.launch(CodeAreaDemoApp.class, args); + } + + @Override + public void init() { + FxSettings.useDirectory(".CodeAreaDemoApp"); + } + + @Override + public void start(Stage stage) throws Exception { + new CodeAreaWindow(null).show(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaDemoPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaDemoPane.java new file mode 100644 index 00000000000..9fdf0dda04a --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaDemoPane.java @@ -0,0 +1,355 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.codearea; + +import java.nio.charset.Charset; +import java.util.Base64; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.SplitPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.stage.Window; +import javafx.util.StringConverter; +import com.oracle.demo.rich.rta.FontOption; +import com.oracle.demo.rich.rta.ROptionPane; +import com.oracle.demo.rich.util.FX; +import jfx.incubator.scene.control.rich.CodeArea; +import jfx.incubator.scene.control.rich.SyntaxDecorator; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Main Panel contains CodeArea, split panes for quick size adjustment, and an option pane. + */ +public class CodeAreaDemoPane extends BorderPane { + public final ROptionPane op; + public final CodeArea control; + + public CodeAreaDemoPane(CodeTextModel m) { + FX.name(this, "CodeAreaDemoPane"); + control = new CodeArea(m); + + SplitPane hsplit = new SplitPane(control, pane()); + FX.name(hsplit, "hsplit"); + hsplit.setBorder(null); + hsplit.setDividerPositions(1.0); + hsplit.setOrientation(Orientation.HORIZONTAL); + + SplitPane vsplit = new SplitPane(hsplit, pane()); + FX.name(vsplit, "vsplit"); + vsplit.setBorder(null); + vsplit.setDividerPositions(1.0); + vsplit.setOrientation(Orientation.VERTICAL); + + FontOption fontOption = new FontOption("font", false, control.fontProperty()); + + CheckBox editable = new CheckBox("editable"); + FX.name(editable, "editable"); + editable.selectedProperty().bindBidirectional(control.editableProperty()); + + CheckBox wrapText = new CheckBox("wrap text"); + FX.name(wrapText, "wrapText"); + wrapText.selectedProperty().bindBidirectional(control.wrapTextProperty()); + + CheckBox displayCaret = new CheckBox("display caret"); + FX.name(displayCaret, "displayCaret"); + displayCaret.selectedProperty().bindBidirectional(control.displayCaretProperty()); + + CheckBox fatCaret = new CheckBox("fat caret"); + FX.name(fatCaret, "fatCaret"); + fatCaret.selectedProperty().addListener((s, p, on) -> { + Node n = control.lookup(".caret"); + if (n != null) { + if (on) { + n.setStyle( + "-fx-stroke-width:2; -fx-stroke:red; -fx-effect:dropshadow(gaussian,rgba(0,0,0,.5),5,0,1,1);"); + } else { + n.setStyle(null); + } + } + }); + + CheckBox highlightCurrentLine = new CheckBox("highlight current line"); + FX.name(highlightCurrentLine, "highlightCurrentLine"); + highlightCurrentLine.selectedProperty().bindBidirectional(control.highlightCurrentParagraphProperty()); + + ComboBox tabSize = new ComboBox<>(); + FX.name(tabSize, "tabSize"); + tabSize.getItems().setAll(1, 2, 3, 4, 8, 16); + tabSize.getSelectionModel().selectedItemProperty().addListener((s, p, v) -> { + control.setTabSize(v); + }); + + CheckBox customPopup = new CheckBox("custom popup menu"); + FX.name(customPopup, "customPopup"); + customPopup.selectedProperty().addListener((s, p, v) -> { + setCustomPopup(v); + }); + + ComboBox contentPadding = new ComboBox<>(); + FX.name(contentPadding, "contentPadding"); + contentPadding.setConverter(new StringConverter() { + @Override + public String toString(Insets x) { + if (x == null) { + return "null"; + } + return String.format( + "T%d, B%d, L%d, R%d", + (int)x.getTop(), + (int)x.getBottom(), + (int)x.getLeft(), + (int)x.getRight() + ); + } + + @Override + public Insets fromString(String s) { + return null; + } + }); + contentPadding.getItems().setAll( + null, + new Insets(1), + new Insets(2), + new Insets(10), + new Insets(22.22), + new Insets(50), + new Insets(100), + new Insets(5, 10, 15, 20) + ); + contentPadding.getSelectionModel().selectedItemProperty().addListener((s, p, v) -> { + control.setContentPadding(v); + }); + + ComboBox lineSpacing = new ComboBox<>(); + FX.name(lineSpacing, "lineSpacing"); + lineSpacing.getItems().setAll( + 0.0, + 5.0, + 31.415 + ); + lineSpacing.getSelectionModel().selectedItemProperty().addListener((s, p, v) -> { + setLineSpacing(v); + }); + + CheckBox lineNumbers = new CheckBox("line numbers"); + FX.name(lineNumbers, "lineNumbers"); + lineNumbers.selectedProperty().bindBidirectional(control.lineNumbersEnabledProperty()); + + ComboBox syntax = new ComboBox<>(); + FX.name(syntax, "syntax"); + syntax.getItems().addAll( + null, + new DemoSyntaxDecorator(), + new JavaSyntaxDecorator() + ); + syntax.setConverter(new StringConverter() { + @Override + public String toString(SyntaxDecorator x) { + return x == null ? "" : x.toString(); + } + + @Override + public SyntaxDecorator fromString(String s) { + return null; + } + }); + syntax.getSelectionModel().selectedItemProperty().addListener((s, p, v) -> { + control.setSyntaxDecorator(v); + }); + + op = new ROptionPane(); + op.option(editable); + op.label("Font:"); + op.option(fontOption); + op.option(wrapText); + op.option(displayCaret); + op.option(fatCaret); + op.option(highlightCurrentLine); + op.option(lineNumbers); + op.label("Tab Size:"); + op.option(tabSize); + op.option(customPopup); + op.label("Content Padding:"); + op.option(contentPadding); + op.label("Line Spacing:"); + op.option(lineSpacing); + op.label("Syntax Highlighter:"); + op.option(syntax); + + setCenter(vsplit); + setRight(op); + + contentPadding.getSelectionModel().selectFirst(); + lineSpacing.getSelectionModel().selectFirst(); + syntax.getSelectionModel().selectFirst(); + } + + protected static Pane pane() { + Pane p = new Pane(); + SplitPane.setResizableWithParent(p, false); + p.setStyle("-fx-background-color:#dddddd;"); + return p; + } + + public Button addButton(String name, Runnable action) { + Button b = new Button(name); + b.setOnAction((ev) -> { + action.run(); + }); + + toolbar().add(b); + return b; + } + + public TBar toolbar() { + if (getTop() instanceof TBar) { + return (TBar)getTop(); + } + + TBar t = new TBar(); + setTop(t); + return t; + } + + public Window getWindow() { + Scene s = getScene(); + if (s != null) { + return s.getWindow(); + } + return null; + } + + public void setOptions(Node n) { + setRight(n); + } + + protected String generateStylesheet(boolean fat) { + String s = ".rich-text-area .caret { -fx-stroke-width:" + (fat ? 2 : 1) + "; }"; + return "data:text/css;base64," + Base64.getEncoder().encodeToString(s.getBytes(Charset.forName("utf-8"))); + } + + protected void setCustomPopup(boolean on) { + if (on) { + ContextMenu m = new ContextMenu(); + m.getItems().add(new MenuItem("Dummy")); // otherwise no popup is shown + m.addEventFilter(Menu.ON_SHOWING, (ev) -> { + m.getItems().clear(); + populatePopupMenu(m.getItems()); + }); + control.setContextMenu(m); + } else { + control.setContextMenu(null); + } + } + + protected void populatePopupMenu(ObservableList items) { + boolean sel = control.hasNonEmptySelection(); + boolean paste = true; // would be easier with Actions (findFormatForPaste() != null); + + MenuItem m; + items.add(m = new MenuItem("Undo")); + m.setOnAction((ev) -> control.undo()); + m.setDisable(!control.isUndoable()); + + items.add(m = new MenuItem("Redo")); + m.setOnAction((ev) -> control.redo()); + m.setDisable(!control.isRedoable()); + + items.add(new SeparatorMenuItem()); + + items.add(m = new MenuItem("Cut")); + m.setOnAction((ev) -> control.cut()); + m.setDisable(!sel); + + items.add(m = new MenuItem("Copy")); + m.setOnAction((ev) -> control.copy()); + m.setDisable(!sel); + + items.add(m = new MenuItem("Paste")); + m.setOnAction((ev) -> control.paste()); + m.setDisable(!paste); + + items.add(new SeparatorMenuItem()); + + items.add(m = new MenuItem("Select All")); + m.setOnAction((ev) -> control.selectAll()); + } + + protected void apply(StyleAttribute attr, V val) { + TextPos ca = control.getCaretPosition(); + TextPos an = control.getAnchorPosition(); + StyleAttrs a = StyleAttrs.builder().set(attr, val).build(); + control.applyStyle(ca, an, a); + } + + protected void setLineSpacing(double x) { + control.setLineSpacing(x); + } + + private void applyStyle(StyleAttribute a, V val) { + TextPos ca = control.getCaretPosition(); + TextPos an = control.getAnchorPosition(); + StyleAttrs m = StyleAttrs.of(a, val); + control.applyStyle(ca, an, m); + } + + // + + public static class TBar extends HBox { + public TBar() { + setFillHeight(true); + setAlignment(Pos.CENTER_LEFT); + setSpacing(2); + } + + public T add(T n) { + getChildren().add(n); + return n; + } + + public void addAll(Node... nodes) { + for (Node n : nodes) { + add(n); + } + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaWindow.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaWindow.java new file mode 100644 index 00000000000..ae00c044804 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/CodeAreaWindow.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.codearea; + +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.MenuBar; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; +import com.oracle.demo.rich.rta.RichTextAreaWindow; +import com.oracle.demo.rich.util.FX; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; + +/** + * CodeArea Demo window + */ +public class CodeAreaWindow extends Stage { + private CodeTextModel model; + public final CodeAreaDemoPane demoPane; + public final Label status; + + public CodeAreaWindow(CodeTextModel m) { + model = (m == null ? new CodeTextModel() : m); + demoPane = new CodeAreaDemoPane(model); + + MenuBar mb = new MenuBar(); + FX.menu(mb, "File"); + FX.item(mb, "New Window", this::newWindow); + FX.separator(mb); + FX.item(mb, "Close Window", this::hide); + FX.separator(mb); + FX.item(mb, "Quit", () -> Platform.exit()); + + status = new Label(); + status.setPadding(new Insets(2, 10, 2, 10)); + + BorderPane bp = new BorderPane(); + bp.setTop(mb); + bp.setCenter(demoPane); + bp.setBottom(status); + + Scene scene = new Scene(bp); + setScene(scene); + setTitle( + "CodeArea Tester JFX:" + System.getProperty("javafx.runtime.version") + + " JDK:" + System.getProperty("java.version") + ); + setWidth(1200); + setHeight(600); + + demoPane.control.caretPositionProperty().addListener((x) -> updateStatus()); + } + + protected void updateStatus() { + RichTextArea t = demoPane.control; + TextPos p = t.getCaretPosition(); + + StringBuilder sb = new StringBuilder(); + + if (p != null) { + sb.append(" line=").append(p.index()); + sb.append(" col=").append(p.offset()); + } + + status.setText(sb.toString()); + } + + protected void newWindow() { + double offset = 20; + + CodeAreaWindow w = new CodeAreaWindow(model); + w.setX(getX() + offset); + w.setY(getY() + offset); + w.setWidth(getWidth()); + w.setHeight(getHeight()); + w.show(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/DemoSyntaxDecorator.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/DemoSyntaxDecorator.java new file mode 100644 index 00000000000..6546526b7c3 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/DemoSyntaxDecorator.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.codearea; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javafx.scene.paint.Color; +import jfx.incubator.scene.control.rich.SyntaxDecorator; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Simple {@code SyntaxDecorator} which emphasizes digits and java keywords. + * This is just a demo. + */ +public class DemoSyntaxDecorator implements SyntaxDecorator { + private static final StyleAttrs DIGITS = StyleAttrs.builder().setTextColor(Color.MAGENTA).build(); + private static final StyleAttrs KEYWORDS = StyleAttrs.builder().setTextColor(Color.GREEN).build(); + private static Pattern PATTERN = initPattern(); + + public DemoSyntaxDecorator() { + } + + @Override + public String toString() { + return "DemoSyntaxDecorator"; + } + + @Override + public RichParagraph createRichParagraph(CodeTextModel model, int index) { + String text = model.getPlainText(index); + RichParagraph.Builder b = RichParagraph.builder(); + if (text != null) { + int len = text.length(); + if (len > 0) { + Matcher m = PATTERN.matcher(text); + int beg = 0; + while (m.find(beg)) { + int start = m.start(); + if (start > beg) { + b.addSegment(text, beg, start, null); + } + int end = m.end(); + boolean digit = (m.end(1) >= 0); + b.addSegment(text, start, end, digit ? DIGITS : KEYWORDS); + beg = end; + } + if (beg < len) { + b.addSegment(text, beg, len, null); + } + } + } + return b.build(); + } + + private static Pattern initPattern() { + String[] keywords = { + "abstract", + "assert", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "default", + "do", + "double", + "else", + "enum", + "extends", + "final", + "finally", + "float", + "for", + "goto", + "if", + "implements", + "import", + "instanceof", + "int", + "interface", + "long", + "native", + "new", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "strictfpv", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "try", + "void", + "volatile", + "while" + }; + + StringBuilder sb = new StringBuilder(); + // digits + sb.append("(\\b\\d+\\b)"); + + // keywords + for (String k : keywords) { + sb.append("|\\b("); + sb.append(k); + sb.append(")\\b"); + } + return Pattern.compile(sb.toString()); + } + + @Override + public void handleChange(CodeTextModel m, TextPos start, TextPos end, int charsTop, int linesAdded, int charsBottom) { + // no-op + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/JavaSyntaxAnalyzer.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/JavaSyntaxAnalyzer.java new file mode 100644 index 00000000000..f99ead51377 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/JavaSyntaxAnalyzer.java @@ -0,0 +1,891 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.codearea; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A simple Java syntax analyzer implemented as a recursive descent parser. + * This is just a demo, as it has no link to the real compiler, does not understand Java language, + * does not take into account version-specific language features, and reports no errors. + * It also does not check validity of numeric literals, allowing malformed octal or binary numbers, + * or values that are too large to be represented. + */ +public class JavaSyntaxAnalyzer { + private boolean DEBUG = false; + + /** Encapsulates a paragraph containing segments with syntax highlighting */ + public static class Line { + private ArrayList segments = new ArrayList<>(); + + /** + * The constructor. + */ + public Line() { + } + + /** + * Adds a segment. + * @param type the segment type + * @param text the segment text + */ + public void addSegment(Type type, String text) { + segments.add(new Segment(type, text)); + } + + /** + * Returns the list of segments. + * @return the list of segments + */ + public List getSegments() { + return segments; + } + + @Override + public String toString() { + return segments.toString(); + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof Line n) { + return segments.equals(n.segments); + } + return false; + } + + @Override + public int hashCode() { + return 0; // we only need equals, don't put a hash table! + } + } + + /** + * Encapsulates a text segment with the same syntax highlight type. + */ + public static class Segment { + private final Type type; + private final String text; + + /** + * The constructor. + * @param type the segment type + * @param text the segment text + */ + public Segment(Type type, String text) { + this.type = type; + this.text = text; + } + + /** + * Returns the segment type. + * @return the segment type + */ + public Type getType() { + return type; + } + + /** + * Returns the segment text. + * @return the segment text + */ + public String getText() { + return text; + } + + @Override + public String toString() { + return type + ":[" + text + "]"; + } + + @Override + public boolean equals(Object x) { + if (x == this) { + return true; + } else if (x instanceof Segment s) { + return + (type == s.type) && + (text.equals(s.text)); + } + return false; + } + + @Override + public int hashCode() { + return 0; // we only need equals, don't put a hash table! + } + } + + /** + * Defines the segment type generated by this analyzer + */ + public enum Type { + CHARACTER, + COMMENT, + KEYWORD, + NUMBER, + OTHER, + STRING, + } + + private enum State { + COMMENT_BLOCK, + COMMENT_LINE, + EOF, + EOL, + KEYWORD, + OTHER, + STRING, + TEXT_BLOCK, + WHITESPACE, + } + + private static final int EOF = -1; + private static Pattern KEYWORDS; + private static Pattern CHARS; + static { init(); } + + private final String text; + private final Matcher keywordMatcher; + private final Matcher charsMatcher; + private int pos; + private int start; + private boolean blockComment; + private State state = State.OTHER; + private int tokenLength; + private ArrayList lines; + private Line currentLine; + + /** + * Creates the syntax analyzer initialized with the specified text. + * The text must have newline ({@code /n}) characters as line delimiters. + * @param text the input text + */ + public JavaSyntaxAnalyzer(String text) { + this.text = text; + this.keywordMatcher = KEYWORDS.matcher(text); + this.charsMatcher = CHARS.matcher(text); + } + + private static void init() { + String[] keywords = { + "abstract", + "assert", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "default", + "do", + "double", + "else", + "enum", + "extends", + "false", + "final", + "finally", + "float", + "for", + "goto", + "if", + "implements", + "import", + "instanceof", + "int", + "interface", + "long", + "native", + "new", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "strictfpv", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "true", + "try", + "var", + "void", + "volatile", + "while" + }; + + StringBuilder sb = new StringBuilder(); + sb.append("\\G"); // match at start of the input in match(pos); + sb.append("("); // capturing group + boolean sep = false; + for (String k : keywords) { + if (sep) { + sb.append("|"); + } else { + sep = true; + } + sb.append("("); // capturing group + sb.append(k); + // TODO add a post-match check instead + sb.append("\\b"); // word boundary + sb.append(")"); // capturing group + } + sb.append(")"); // capturing group + + KEYWORDS = Pattern.compile(sb.toString()); + + String charsPattern = + "(\\G\\\\[bfnrt'\"\\\\]')|" + // \b' + \f' + \n' + \r' + \t' + \'' + \"' + \\' + "(\\G[^\\\\u]')|" + // any char followed by ', except u and \ + "(\\G\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]')" // unicode escapes + ; + CHARS = Pattern.compile(charsPattern); + } + + // returns the length of java keyword, 0 if not a java keyword + private int matchJavaKeyword() { + int c = charAt(-1); + if (Character.isJavaIdentifierPart(c)) { + return 0; + } + + c = charAt(0); + switch (c) { + case 'a': + case 'b': + case 'c': + case 'd': + case 'e': + case 'f': + case 'g': + case 'i': + case 'l': + case 'n': + case 'p': + case 'r': + case 's': + case 't': + case 'v': + case 'w': + break; + default: + return 0; + }; + + if (keywordMatcher.find(pos)) { + int start = keywordMatcher.start(); + int end = keywordMatcher.end(); + switch (charAt(end - pos)) { + case '.': + return 0; + } + + return (end - start); + } + return 0; + } + + // returns the length of the character, or 0 if not a character + private int matchCharacter() { + if(charsMatcher.find(pos + 1)) { + int start = charsMatcher.start(); + int end = charsMatcher.end(); + return (end - start); + } + return 0; + } + + private int peek() { + if (pos < text.length()) { + return text.charAt(pos); + } + return EOF; + } + + private Type type(State s) { + switch(s) { + case COMMENT_BLOCK: + return Type.COMMENT; + case COMMENT_LINE: + return Type.COMMENT; + case EOF: + return Type.OTHER; + case EOL: + return Type.OTHER; + case KEYWORD: + return Type.KEYWORD; + case OTHER: + return Type.OTHER; + case STRING: + return Type.STRING; + case TEXT_BLOCK: + return Type.STRING; + case WHITESPACE: + return Type.OTHER; + default: + throw new Error("?" + s); + } + } + + private void addSegment() { + Type type = type(state); + addSegment(type); + } + + private void addSegment(Type type) { + if (pos > start) { + String s = text.substring(start, pos); + + if (currentLine == null) { + currentLine = new Line(); + } + currentLine.addSegment(type, s); + + start = pos; + if(DEBUG) System.out.println(" " + type + ":[" + s + "]"); // FIX + } + } + + private void addNewLine() { + if (currentLine == null) { + currentLine = new Line(); + } + lines.add(currentLine); + currentLine = null; + if(DEBUG) System.out.println(" "); // FIX + } + + private boolean match(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (charAt(i) != pattern.charAt(i)) { + return false; + } + } + tokenLength = pattern.length(); + return true; + } + + // relative to 'pos' + private int charAt(int ix) { + ix += pos; + if ((ix >= 0) && (ix < text.length())) { + return text.charAt(ix); + } + return EOF; + } + + // leading: """(whitespace)\n trailing: """(whitespace); + private boolean isTextBlock(boolean leading) { + for (int i = 0; ; i++) { + int c = charAt(i); + switch (c) { + case '"': + if (i >= 3) { + return false; + } + break; + case ' ': + case '\t': + if (i < 3) { + return false; + } + break; + case '\n': + if (leading) { + tokenLength = i; + return true; + } else { + return false; + } + case ';': + if (i < 3) { + return false; + } + if (!leading) { + tokenLength = i; + return true; + } + case -1: + default: + return false; + } + } + } + + // relative to pos + private boolean isBoundedByDigit(boolean hex, int ix, boolean increase) { + for(;;) { + int c = charAt(ix); + switch (c) { + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + return true; + case 'a': + case 'A': + case 'b': + case 'B': + case 'c': + case 'C': + case 'd': + case 'D': + case 'e': + case 'E': + case 'f': + case 'F': + return hex ? true : false; + case '_': + break; + default: + return false; + } + + ix += (increase ? 1 : -1); + } + } + + // TODO move up + private enum Phase { + S_BEG, // beginning of significand, before period + S_PER, // decimal point in the significand + S_END, // after period in significand + E_DIV, // exponent divider ('e' or 'E') + E_SIG, // exponent sign + E_BEG, // exponent before decimal point + E_END, // after decimal point in the exponent + HEX, // hexadecimal literal + BIN, // binary literal + UNKNOWN + } + private Phase phase; + private boolean hasSignificand; + private boolean hasExponent; + + private int matchNumber() { + int c = charAt(0); + switch (c) { + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + c = charAt(-1); + switch (c) { + case -1: + break; + default: + if (Character.isJavaIdentifierPart(c)) { + return 0; + } + } + break; + case '.': + break; + default: + return 0; + } + + phase = Phase.UNKNOWN; + hasSignificand = false; + hasExponent = false; + + for (int i = 0; ; i++) { + c = charAt(i); + char ch = (char)c; // FIX + switch (c) { + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + switch (phase) { + case HEX: + case BIN: + // not validating binary literals + hasSignificand = true; + break; + case UNKNOWN: + phase = Phase.S_BEG; + hasSignificand = true; + break; + case S_BEG: + hasSignificand = true; + break; + case S_PER: + phase = Phase.S_END; + hasSignificand = true; + break; + case E_DIV: + case E_SIG: + phase = Phase.E_BEG; + hasExponent = true; + break; + } + break; + case 'a': + case 'A': + case 'c': + case 'C': + switch (phase) { + case HEX: + hasSignificand = true; + break; + default: + return 0; + } + break; + case '.': + switch (phase) { + case UNKNOWN: + case S_BEG: + phase = Phase.S_PER; + break; + default: + return 0; + } + break; + case '_': + switch (phase) { + case HEX: + if (!(isBoundedByDigit(true, i - 1, false) && isBoundedByDigit(true, i + 1, true))) { + return 0; + } + break; + case BIN: + case S_BEG: + case S_END: + case E_BEG: + case E_END: + if (!(isBoundedByDigit(false, i - 1, false) && isBoundedByDigit(false, i + 1, true))) { + return 0; + } + break; + default: + return 0; + } + break; + case 'e': + case 'E': + switch (phase) { + case HEX: + hasSignificand = true; + break; + case S_PER: + if(!hasSignificand) { + return 0; + } + // fall through + case S_BEG: + case S_END: + phase = Phase.E_DIV; + break; + default: + return 0; + } + break; + case 'd': + case 'D': + case 'f': + case 'F': + switch (phase) { + case HEX: + hasSignificand = true; + break; + case S_BEG: + case S_PER: + case S_END: + return hasSignificand ? (i + 1) : 0; + case E_BEG: + case E_END: + return hasExponent ? (i + 1) : 0; + default: + return 0; + } + break; + case '+': + case '-': + switch (phase) { + case E_DIV: + phase = Phase.E_SIG; + break; + case S_BEG: + case S_END: + case S_PER: + return i; + default: + return 0; + } + break; + case 'l': + case 'L': + switch (phase) { + case HEX: + case BIN: + case S_BEG: + return i + 1; + } + return 0; + case 'x': + case 'X': + if ((i == 1) && (charAt(i - 1) == '0')) { + phase = Phase.HEX; + hasSignificand = false; + } else { + return 0; + } + break; + case 'b': + case 'B': + switch (phase) { + case HEX: + hasSignificand = true; + break; + default: + if ((i == 1) && (charAt(i - 1) == '0')) { + phase = Phase.BIN; + hasSignificand = false; + } else { + return 0; + } + } + break; + case -1: + default: + switch (phase) { + case S_PER: + case HEX: + case BIN: + return hasSignificand ? i : 0; + case S_BEG: + case S_END: + case E_BEG: + case E_END: + return i; + default: + return 0; + } + } + } + } + + /** + * Analyzes the input text, producing a list of {@code Line}s containing syntax information. + * @return the list of lines with syntax highlighting + */ + public List analyze() { + if(DEBUG) System.out.println("analyze"); // FIX + lines = new ArrayList<>(); + start = 0; + + for (;;) { + tokenLength = 0; + int c = peek(); + + switch (c) { + case '*': + switch (state) { + case COMMENT_BLOCK: + if (match("*/")) { + pos += tokenLength; + addSegment(); + state = State.OTHER; + continue; + } + } + break; + case '\n': + addSegment(); + addNewLine(); + pos++; + start = pos; + switch (state) { + case COMMENT_BLOCK: + case TEXT_BLOCK: + break; + default: + state = State.OTHER; + break; + } + continue; + case '/': + switch(state) { + case COMMENT_BLOCK: + case COMMENT_LINE: + case STRING: + case TEXT_BLOCK: + break; + default: + if (match("/*")) { + addSegment(); + pos += tokenLength; + state = State.COMMENT_BLOCK; + continue; + } else if (match("//")) { + addSegment(); + pos += tokenLength; + state = State.COMMENT_LINE; + continue; + } + } + break; + case '"': + switch(state) { + case COMMENT_BLOCK: + case COMMENT_LINE: + break; + case TEXT_BLOCK: + if(isTextBlock(false)) { + pos += tokenLength; + addSegment(); + state = State.OTHER; + continue; + } + break; + case STRING: + pos++; + addSegment(); + state = State.OTHER; + continue; + default: + if(isTextBlock(true)) { + addSegment(); + pos += tokenLength; + state = State.TEXT_BLOCK; + continue; + } else { + addSegment(); + state = State.STRING; + } + break; + } + break; + case '\'': + switch(state) { + case COMMENT_BLOCK: + case COMMENT_LINE: + case STRING: + break; + default: + switch (charAt(1)) { + case '\n': + pos++; + continue; + } + tokenLength = matchCharacter(); + if (tokenLength > 0) { + addSegment(); + pos += (tokenLength + 1); + addSegment(Type.CHARACTER); + state = State.OTHER; + continue; + } + break; + } + break; + case '\\': + switch (state) { + case STRING: + switch (charAt(1)) { + case '\n': + break; + default: + pos++; + break; + } + break; + } + break; + case EOF: + addSegment(); + if (currentLine != null) { + lines.add(currentLine); + } + if (lines.size() == 0) { + lines.add(new Line()); + } + return lines; + default: + switch (state) { + case OTHER: + tokenLength = matchJavaKeyword(); + if (tokenLength > 0) { + addSegment(); + pos += tokenLength; + addSegment(Type.KEYWORD); + state = State.OTHER; + continue; + } + tokenLength = matchNumber(); + if (tokenLength > 0) { + addSegment(); + pos += tokenLength; + addSegment(Type.NUMBER); + state = State.OTHER; + continue; + } + break; + default: + break; + } + break; + } + + pos++; + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/JavaSyntaxDecorator.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/JavaSyntaxDecorator.java new file mode 100644 index 00000000000..81c5b586ec8 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/codearea/JavaSyntaxDecorator.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.codearea; + +import java.util.ArrayList; +import java.util.List; +import javafx.scene.paint.Color; +import jfx.incubator.scene.control.rich.SyntaxDecorator; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * A simple {@code SyntaxDecorator} for Java source files. + * + * This is just a demo, as it has no link to the real compiler, does not understand Java language + * and does not take into account version-specific language features. + */ +public class JavaSyntaxDecorator implements SyntaxDecorator { + private static final StyleAttrs CHARACTER = mkStyle(Color.BLUE); + private static final StyleAttrs COMMENT = mkStyle(Color.RED); + private static final StyleAttrs KEYWORD = mkStyle(Color.GREEN); + private static final StyleAttrs NUMBER = mkStyle(Color.MAGENTA); + private static final StyleAttrs OTHER = mkStyle(Color.BLACK); + private static final StyleAttrs STRING = mkStyle(Color.BLUE); + private ArrayList paragraphs; + + public JavaSyntaxDecorator() { + } + + @Override + public String toString() { + return "JavaSyntaxDecorator"; + } + + + @Override + public void handleChange(CodeTextModel m, TextPos start, TextPos end, int top, int lines, int btm) { + // in theory, it may reuse the portions that haven't changed + // but java files are short enough to re-analyze in full each time + reload(m); + } + + @Override + public RichParagraph createRichParagraph(CodeTextModel model, int index) { + if ((paragraphs == null) || (index >= paragraphs.size())) { + return RichParagraph.builder().build(); + } + return paragraphs.get(index); + } + + private static StyleAttrs mkStyle(Color c) { + return StyleAttrs.builder().setTextColor(c).build(); + } + + private void reload(CodeTextModel model) { + String text = getPlainText(model); + JavaSyntaxAnalyzer a = new JavaSyntaxAnalyzer(text); + List res = a.analyze(); + paragraphs = translate(res); + } + + private String getPlainText(CodeTextModel model) { + StringBuilder sb = new StringBuilder(65536); + int sz = model.size(); + boolean nl = false; + for (int i = 0; i < sz; i++) { + if (nl) { + sb.append('\n'); + } else { + nl = true; + } + String s = model.getPlainText(i); + if (s != null) { + sb.append(s); + } + } + return sb.toString(); + } + + private ArrayList translate(List lines) { + ArrayList res = new ArrayList<>(lines.size()); + for (JavaSyntaxAnalyzer.Line line : lines) { + RichParagraph p = createParagraph(line); + res.add(p); + } + return res; + } + + private RichParagraph createParagraph(JavaSyntaxAnalyzer.Line line) { + RichParagraph.Builder b = RichParagraph.builder(); + for (JavaSyntaxAnalyzer.Segment seg : line.getSegments()) { + JavaSyntaxAnalyzer.Type t = seg.getType(); + String text = seg.getText(); + StyleAttrs a = getStyleAttrs(t); + b.addSegment(text, a); + } + return b.build(); + } + + private StyleAttrs getStyleAttrs(JavaSyntaxAnalyzer.Type t) { + switch(t) { + case CHARACTER: + return CHARACTER; + case COMMENT: + return COMMENT; + case KEYWORD: + return KEYWORD; + case NUMBER: + return NUMBER; + case STRING: + return STRING; + } + return OTHER; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/common/Styles.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/common/Styles.java new file mode 100644 index 00000000000..a24cd0ef927 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/common/Styles.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.common; + +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +public class Styles { + // TODO perhaps we should specifically set fonts to be used, + // and couple that with the app stylesheet + public static final StyleAttrs TITLE = s("System", 24, true); + public static final StyleAttrs HEADING = s("System", 18, true); + public static final StyleAttrs SUBHEADING = s("System", 14, true); + public static final StyleAttrs BODY = s("System", 12, false); + public static final StyleAttrs MONOSPACED = s("Monospace", 12, false); + + private static StyleAttrs s(String font, double size, boolean bold) { + return StyleAttrs.builder(). + setFontFamily(font). + setFontSize(size). + setBold(bold). + build(); + } + + public static StyleAttrs getStyleAttrs(TextStyle st) { + switch (st) { + case BODY: + return BODY; + case HEADING: + return HEADING; + case MONOSPACED: + return MONOSPACED; + case TITLE: + return TITLE; + case SUBHEADING: + return SUBHEADING; + default: + return BODY; + } + } + + public static TextStyle guessTextStyle(StyleAttrs attrs) { + if (attrs != null) { + if (attrs.isEmpty()) { + return TextStyle.BODY; + } + StyleAttribute[] keys = { + StyleAttrs.BOLD, + StyleAttrs.FONT_FAMILY, + StyleAttrs.FONT_SIZE + }; + for (TextStyle st : TextStyle.values()) { + StyleAttrs a = getStyleAttrs(st); + if (match(attrs, a, keys)) { + return st; + } + } + } + return null; + } + + private static boolean match(StyleAttrs attrs, StyleAttrs builtin, StyleAttribute[] keys) { + for (StyleAttribute k : keys) { + Object v1 = attrs.get(k); + Object v2 = builtin.get(k); + if (!eq(v1, v2)) { + return false; + } + } + return true; + } + + private static boolean eq(Object a, Object b) { + if (a == null) { + return b == null; + } + return a.equals(b); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/common/TextStyle.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/common/TextStyle.java new file mode 100644 index 00000000000..71ddee82ab5 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/common/TextStyle.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.common; + +import javafx.util.StringConverter; + +/** + * Build-in text styles. + */ +public enum TextStyle { + TITLE, + HEADING, + SUBHEADING, + BODY, + MONOSPACED; + + public String getDisplayName() { + switch (this) { + case BODY: + return "Body"; + case HEADING: + return "Heading"; + case MONOSPACED: + return "Monospaced"; + case TITLE: + return "Title"; + case SUBHEADING: + return "Subheading"; + default: + return "?" + this; + } + } + + public static StringConverter converter() { + return new StringConverter() { + @Override + public String toString(TextStyle t) { + return t == null ? null : t.getDisplayName(); + } + + @Override + public TextStyle fromString(String s) { + for (TextStyle t : TextStyle.values()) { + if (s.equals(t.getDisplayName())) { + return t; + } + } + return TextStyle.BODY; + } + }; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/Actions.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/Actions.java new file mode 100644 index 00000000000..2b1064946ac --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/Actions.java @@ -0,0 +1,450 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.editor; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.Optional; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonBar.ButtonData; +import javafx.scene.control.ButtonType; +import javafx.scene.input.DataFormat; +import javafx.scene.paint.Color; +import javafx.stage.FileChooser; +import javafx.stage.Window; +import com.oracle.demo.rich.common.Styles; +import com.oracle.demo.rich.common.TextStyle; +import com.oracle.demo.rich.util.FX; +import com.oracle.demo.rich.util.FxAction; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.SelectionSegment; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.ContentChange; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; +import jfx.incubator.scene.control.rich.model.RichTextFormatHandler; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * This is a bit of hack. JavaFX has no actions (yet), so here we are using FxActions from + * https://github.com/andy-goryachev/AppFramework (with permission from the author). + * Ideally, these actions should be created upon demand and managed by the control, because + * control knows when the enabled state of each action changes. + *

+ * This class adds a listener to the model and updates the states of all the actions. + * (The model does not change in this application). + */ +public class Actions { + // file + public final FxAction newDocument = new FxAction(this::newDocument); + public final FxAction open = new FxAction(this::open); + public final FxAction save = new FxAction(this::save); + // style + public final FxAction bold = new FxAction(this::bold); + public final FxAction italic = new FxAction(this::italic); + public final FxAction strikeThrough = new FxAction(this::strikeThrough); + public final FxAction underline = new FxAction(this::underline); + // editing + public final FxAction copy = new FxAction(this::copy); + public final FxAction cut = new FxAction(this::cut); + public final FxAction paste = new FxAction(this::paste); + public final FxAction pasteUnformatted = new FxAction(this::pasteUnformatted); + public final FxAction redo = new FxAction(this::redo); + public final FxAction selectAll = new FxAction(this::selectAll); + public final FxAction undo = new FxAction(this::undo); + // view + public final FxAction wrapText = new FxAction(); + + private final RichTextArea control; + private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper(); + private final ReadOnlyObjectWrapper file = new ReadOnlyObjectWrapper<>(); + private final SimpleObjectProperty styles = new SimpleObjectProperty<>(); + private final SimpleObjectProperty textStyle = new SimpleObjectProperty<>(); + + public Actions(RichTextArea control) { + this.control = control; + + // undo/redo actions + redo.disabledProperty().bind(control.redoableProperty().not()); + undo.disabledProperty().bind(control.undoableProperty().not()); + + undo.disabledProperty().bind(Bindings.createBooleanBinding(() -> { + return !control.isUndoable(); + }, control.undoableProperty())); + + redo.disabledProperty().bind(Bindings.createBooleanBinding(() -> { + return !control.isRedoable(); + }, control.redoableProperty())); + + wrapText.selectedProperty().bindBidirectional(control.wrapTextProperty()); + + control.getModel().addChangeListener(new StyledTextModel.Listener() { + @Override + public void onContentChange(ContentChange ch) { + handleEdit(); + } + }); + + control.caretPositionProperty().addListener((x) -> { + handleCaret(); + }); + + control.selectionProperty().addListener((p) -> { + updateSourceStyles(); + }); + + styles.addListener((s,p,a) -> { + bold.setSelected(hasStyle(a, StyleAttrs.BOLD), false); + italic.setSelected(hasStyle(a, StyleAttrs.ITALIC), false); + strikeThrough.setSelected(hasStyle(a, StyleAttrs.STRIKE_THROUGH), false); + underline.setSelected(hasStyle(a, StyleAttrs.UNDERLINE), false); + }); + + updateSourceStyles(); + + handleEdit(); + handleCaret(); + setModified(false); + } + + private boolean hasStyle(StyleAttrs attrs, StyleAttribute a) { + return attrs == null ? false : Boolean.TRUE.equals(attrs.get(a)); + } + + public final ObjectProperty textStyleProperty() { + return textStyle; + } + + public final ReadOnlyBooleanProperty modifiedProperty() { + return modified.getReadOnlyProperty(); + } + + public final boolean isModified() { + return modified.get(); + } + + private void setModified(boolean on) { + modified.set(on); + } + + public final ReadOnlyObjectProperty fileNameProperty() { + return file.getReadOnlyProperty(); + } + + public final File getFile() { + return file.get(); + } + + private void handleEdit() { + setModified(true); + } + + private void handleCaret() { + boolean sel = control.hasNonEmptySelection(); + StyleAttrs a = control.getActiveStyleAttrs(); + + cut.setEnabled(sel); + copy.setEnabled(sel); + + bold.setSelected(a.getBoolean(StyleAttrs.BOLD), false); + italic.setSelected(a.getBoolean(StyleAttrs.ITALIC), false); + underline.setSelected(a.getBoolean(StyleAttrs.UNDERLINE), false); + strikeThrough.setSelected(a.getBoolean(StyleAttrs.STRIKE_THROUGH), false); + } + + private void toggle(StyleAttribute attr) { + TextPos start = control.getAnchorPosition(); + TextPos end = control.getCaretPosition(); + if (start == null) { + return; + } else if (start.equals(end)) { + // apply to the whole paragraph + int ix = start.index(); + start = new TextPos(ix, 0); + end = control.getParagraphEnd(ix); + } + + StyleAttrs a = control.getActiveStyleAttrs(); + boolean on = !a.getBoolean(attr); + a = StyleAttrs.builder().set(attr, on).build(); + control.applyStyle(start, end, a); + } + + private void apply(StyleAttribute attr, T value) { + TextPos start = control.getAnchorPosition(); + TextPos end = control.getCaretPosition(); + if (start == null) { + return; + } else if (start.equals(end)) { + // apply to the whole paragraph + int ix = start.index(); + start = new TextPos(ix, 0); + end = control.getParagraphEnd(ix); + } + + StyleAttrs a = control.getActiveStyleAttrs(); + a = StyleAttrs.builder().set(attr, value).build(); + control.applyStyle(start, end, a); + } + + // TODO need to bind selected item in the combo + public void setFontSize(Integer size) { + apply(StyleAttrs.FONT_SIZE, size.doubleValue()); + } + + // TODO need to bind selected item in the combo + public void setFontName(String name) { + apply(StyleAttrs.FONT_FAMILY, name); + } + + public void setTextColor(Color color) { + apply(StyleAttrs.TEXT_COLOR, color); + } + + private void newDocument() { + if (askToSave()) { + return; + } + control.setModel(new EditableRichTextModel()); + setModified(false); + } + + private void open() { + if (askToSave()) { + return; + } + + FileChooser ch = new FileChooser(); + ch.setTitle("Open File"); + // TODO add extensions + Window w = FX.getParentWindow(control); + File f = ch.showOpenDialog(w); + if (f != null) { + try { + readFile(f, RichTextFormatHandler.DATA_FORMAT); + } catch (Exception e) { + new ExceptionDialog(control, e).open(); + } + } + } + + // FIX this is too simplistic, need save() and save as... + private void save() { + File f = getFile(); + if (f == null) { + FileChooser ch = new FileChooser(); + ch.setTitle("Save File"); + // TODO add extensions + Window w = FX.getParentWindow(control); + f = ch.showSaveDialog(w); + if (f == null) { + return; + } + } + try { + writeFile(f, RichTextFormatHandler.DATA_FORMAT); + } catch (Exception e) { + new ExceptionDialog(control, e).open(); + } + } + + // returns true if the user chose to Cancel + private boolean askToSave() { + if (isModified()) { + // alert: has been modified. do you want to save? + Alert alert = new Alert(AlertType.CONFIRMATION); + alert.initOwner(FX.getParentWindow(control)); + alert.setTitle("Document is Modified"); + alert.setHeaderText("Do you want to save this document?"); + ButtonType delete = new ButtonType("Delete"); + ButtonType cancel = new ButtonType("Cancel", ButtonData.CANCEL_CLOSE); + ButtonType save = new ButtonType("Save", ButtonData.APPLY); + alert.getButtonTypes().setAll( + delete, + cancel, + save + ); + + File f = getFile(); + SavePane sp = new SavePane(); + sp.setFile(f); + alert.getDialogPane().setContent(sp); + Optional result = alert.showAndWait(); + if (result.isPresent()) { + ButtonType t = result.get(); + if (t == delete) { + return false; + } else if (t == cancel) { + return true; + } else { + // save using info in the panel + f = sp.getFile(); + DataFormat fmt = sp.getFileFormat(); + // FIX + fmt = RichTextFormatHandler.DATA_FORMAT; + + try { + writeFile(f, fmt); + } catch (Exception e) { + new ExceptionDialog(control, e).open(); + return true; + } + } + } else { + return true; + } + } + return false; + } + + private void readFile(File f, DataFormat fmt) throws Exception { + try (FileInputStream in = new FileInputStream(f)) { + control.read(fmt, in); + file.set(f); + setModified(false); + } + } + + private void writeFile(File f, DataFormat fmt) throws Exception { + try (FileOutputStream out = new FileOutputStream(f)) { + control.write(fmt, out); + file.set(f); + setModified(false); + } + } + + public void copy() { + control.copy(); + } + + public void cut() { + control.cut(); + } + + public void paste() { + control.paste(); + } + + public void pasteUnformatted() { + control.pastePlainText(); + } + + public void selectAll() { + control.selectAll(); + } + + public void redo() { + control.redo(); + } + + public void undo() { + control.undo(); + } + + public void bold() { + toggleStyle(StyleAttrs.BOLD); + } + + public void italic() { + toggleStyle(StyleAttrs.ITALIC); + } + + public void strikeThrough() { + toggleStyle(StyleAttrs.STRIKE_THROUGH); + } + + public void underline() { + toggleStyle(StyleAttrs.UNDERLINE); + } + + private void toggleStyle(StyleAttribute attr) { + TextPos start = control.getAnchorPosition(); + TextPos end = control.getCaretPosition(); + if (start == null) { + return; + } else if (start.equals(end)) { + // apply to the whole paragraph + int ix = start.index(); + start = new TextPos(ix, 0); + end = control.getParagraphEnd(ix); + } + + StyleAttrs a = control.getActiveStyleAttrs(); + boolean on = !a.getBoolean(attr); + a = StyleAttrs.builder().set(attr, on).build(); + control.applyStyle(start, end, a); + updateSourceStyles(); + } + + public void setTextStyle(TextStyle st) { + TextPos start = control.getAnchorPosition(); + TextPos end = control.getCaretPosition(); + if (start == null) { + return; + } else if (start.equals(end)) { + TextStyle cur = Styles.guessTextStyle(control.getActiveStyleAttrs()); + if (cur == st) { + return; + } + // apply to the whole paragraph + int ix = start.index(); + start = new TextPos(ix, 0); + end = control.getParagraphEnd(ix); + } + + StyleAttrs a = Styles.getStyleAttrs(st); + control.applyStyle(start, end, a); + updateSourceStyles(); + } + + private void updateSourceStyles() { + StyleAttrs a = getSourceStyleAttrs(); + if (a != null) { + styles.set(a); + + TextStyle st = Styles.guessTextStyle(a); + textStyle.set(st); + } + } + + private StyleAttrs getSourceStyleAttrs() { + SelectionSegment sel = control.getSelection(); + if ((sel == null) || (!sel.isCollapsed())) { + return null; + } + return control.getActiveStyleAttrs(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/ExceptionDialog.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/ExceptionDialog.java new file mode 100644 index 00000000000..58bb43209de --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/ExceptionDialog.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.editor; + +import java.io.FileNotFoundException; +import java.io.PrintWriter; +import java.io.StringWriter; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class ExceptionDialog extends Alert { + public ExceptionDialog(Node owner, Throwable err) { + super(AlertType.ERROR); + + setTitle("An Error Occurred"); + //setHeaderText(""); + //setContentText(""); + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + err.printStackTrace(pw); + String text = sw.toString(); + + Label label = new Label("The exception stacktrace:"); + + TextArea textArea = new TextArea(text); + textArea.setEditable(false); + textArea.setWrapText(false); + + textArea.setMaxWidth(Double.MAX_VALUE); + textArea.setMaxHeight(Double.MAX_VALUE); + GridPane.setVgrow(textArea, Priority.ALWAYS); + GridPane.setHgrow(textArea, Priority.ALWAYS); + + GridPane expContent = new GridPane(); + expContent.setMaxWidth(Double.MAX_VALUE); + expContent.add(label, 0, 0); + expContent.add(textArea, 0, 1); + + getDialogPane().setExpandableContent(expContent); + } + + public void open() { + showAndWait(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoApp.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoApp.java new file mode 100644 index 00000000000..05d366cf23e --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoApp.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.editor; + +import javafx.application.Application; +import javafx.stage.Stage; +import com.oracle.demo.rich.settings.FxSettings; + +/** + * Rich Text Editor Demo Application. + */ +public class RichEditorDemoApp extends Application { + public static void main(String[] args) { + Application.launch(RichEditorDemoApp.class, args); + } + + @Override + public void init() { + FxSettings.useDirectory(".RichEditorDemoApp"); + } + + @Override + public void start(Stage stage) throws Exception { + new RichEditorDemoWindow().show(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoPane.java new file mode 100644 index 00000000000..f5ae0fb5283 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoPane.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.editor; + +import java.util.List; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.ToolBar; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.BorderPane; +import javafx.scene.text.Font; +import com.oracle.demo.rich.common.TextStyle; +import com.oracle.demo.rich.util.FX; +import jfx.incubator.scene.control.input.KeyBinding; +import jfx.incubator.scene.control.rich.RichTextArea; + +/** + * Main Panel. + */ +public class RichEditorDemoPane extends BorderPane { + public final RichTextArea control; + public final Actions actions; + private final ComboBox fontName; + private final ComboBox fontSize; + private final ColorPicker textColor; + private final ComboBox textStyle; + + public RichEditorDemoPane() { + FX.name(this, "RichEditorDemoPane"); + + control = new RichTextArea(); + // custom function + control.getInputMap().register(KeyBinding.shortcut(KeyCode.W), () -> { + System.out.println("Custom function: W key is pressed"); + }); + + actions = new Actions(control); + control.setContextMenu(createContextMenu()); + + fontName = new ComboBox<>(); + fontName.getItems().setAll(collectFonts()); + fontName.setOnAction((ev) -> { + actions.setFontName(fontName.getSelectionModel().getSelectedItem()); + }); + + fontSize = new ComboBox<>(); + fontSize.getItems().setAll( + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 18, + 20, + 22, + 24, + 28, + 32, + 36, + 48, + 72, + 96, + 128 + ); + fontSize.setOnAction((ev) -> { + actions.setFontSize(fontSize.getSelectionModel().getSelectedItem()); + }); + + textColor = new ColorPicker(); + // TODO save/restore custom colors + FX.tooltip(textColor, "Text Color"); + // FIX there is no API for this! why is this a property of a skin, not the control?? + // https://stackoverflow.com/questions/21246137/remove-text-from-colour-picker + textColor.setStyle("-fx-color-label-visible: false ;"); + textColor.setOnAction((ev) -> { + actions.setTextColor(textColor.getValue()); + }); + + textStyle = new ComboBox<>(); + textStyle.getItems().setAll(TextStyle.values()); + textStyle.setConverter(TextStyle.converter()); + textStyle.setOnAction((ev) -> { + updateTextStyle(); + }); + + setTop(createToolBar()); + setCenter(control); + + actions.textStyleProperty().addListener((s,p,c) -> { + setTextStyle(c); + }); + } + + private ToolBar createToolBar() { + ToolBar t = new ToolBar(); + FX.add(t, fontName); + FX.add(t, fontSize); + FX.add(t, textColor); + FX.space(t); + // TODO background + // TODO alignment + // TODO bullet + // TODO space left (indent left, indent right) + // TODO line spacing + FX.toggleButton(t, "𝐁", "Bold text", actions.bold); + FX.toggleButton(t, "𝐼", "Bold text", actions.italic); + FX.toggleButton(t, "S\u0336", "Strike through text", actions.strikeThrough); + FX.toggleButton(t, "U\u0332", "Underline text", actions.underline); + FX.add(t, textStyle); + FX.space(t); + FX.toggleButton(t, "W", "Wrap Text", actions.wrapText); + // TODO line numbers + return t; + } + + private ContextMenu createContextMenu() { + ContextMenu m = new ContextMenu(); + FX.item(m, "Undo", actions.undo); + FX.item(m, "Redo", actions.redo); + FX.separator(m); + FX.item(m, "Cut", actions.cut); + FX.item(m, "Copy", actions.copy); + FX.item(m, "Paste", actions.paste); + FX.item(m, "Paste and Retain Style", actions.pasteUnformatted); + FX.separator(m); + FX.item(m, "Select All", actions.selectAll); + FX.separator(m); + // TODO under "Style" submenu? + FX.item(m, "Bold", actions.bold); + FX.item(m, "Italic", actions.italic); + FX.item(m, "Strike Through", actions.strikeThrough); + FX.item(m, "Underline", actions.underline); + return m; + } + + private static List collectFonts() { + return Font.getFamilies(); + } + + private void updateTextStyle() { + TextStyle st = textStyle.getSelectionModel().getSelectedItem(); + if (st != null) { + actions.setTextStyle(st); + } + } + + public void setTextStyle(TextStyle v) { + textStyle.setValue(v); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoWindow.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoWindow.java new file mode 100644 index 00000000000..5e9224d3ada --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/RichEditorDemoWindow.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.editor; + +import java.io.File; +import javafx.application.Platform; +import javafx.geometry.Insets; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.TextPos; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.MenuBar; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; +import com.oracle.demo.rich.rta.RichTextAreaWindow; +import com.oracle.demo.rich.util.FX; + +/** + * Rich Editor Demo window + */ +public class RichEditorDemoWindow extends Stage { + public final RichEditorDemoPane pane; + public final Label status; + + public RichEditorDemoWindow() { + pane = new RichEditorDemoPane(); + + status = new Label(); + status.setPadding(new Insets(2, 10, 2, 10)); + + BorderPane bp = new BorderPane(); + bp.setTop(createMenu()); + bp.setCenter(pane); + bp.setBottom(status); + + Scene scene = new Scene(bp); + + // TODO input map for the window: add shortcut-S for saving + + setScene(scene); + setWidth(1200); + setHeight(600); + + pane.control.caretPositionProperty().addListener((x) -> { + updateStatus(); + }); + pane.actions.modifiedProperty().addListener((x) -> { + updateTitle(); + }); + pane.actions.fileNameProperty().addListener((x) -> { + updateTitle(); + }); + updateStatus(); + updateTitle(); + } + + private MenuBar createMenu() { + Actions actions = pane.actions; + MenuBar m = new MenuBar(); + // file + FX.menu(m, "File"); + FX.item(m, "New", actions.newDocument); + FX.item(m, "Open...", actions.open); + FX.separator(m); + FX.item(m, "Save...", actions.save); + // TODO print? + FX.item(m, "Quit", () -> Platform.exit()); + + // edit + FX.menu(m, "Edit"); + FX.item(m, "Undo", actions.undo); + FX.item(m, "Redo", actions.redo); + FX.separator(m); + FX.item(m, "Cut", actions.cut); + FX.item(m, "Copy", actions.copy); + FX.item(m, "Paste", actions.paste); + FX.item(m, "Paste and Retain Style", actions.pasteUnformatted); + + // format + FX.menu(m, "Format"); + FX.item(m, "Bold", actions.bold); + FX.item(m, "Italic", actions.italic); + FX.item(m, "Strike Through", actions.strikeThrough); + FX.item(m, "Underline", actions.underline); + + // view + FX.menu(m, "View"); + FX.checkItem(m, "Wrap Text", actions.wrapText); + // TODO line numbers + // TODO line spacing + + // help + FX.menu(m, "Help"); + // TODO about + return m; + } + + private void updateStatus() { + RichTextArea t = pane.control; + TextPos p = t.getCaretPosition(); + + StringBuilder sb = new StringBuilder(); + + if (p != null) { + sb.append(" Line: ").append(p.index() + 1); + sb.append(" Column: ").append(p.offset() + 1); + } + + status.setText(sb.toString()); + } + + private void updateTitle() { + File f = pane.actions.getFile(); + boolean modified = pane.actions.isModified(); + + StringBuilder sb = new StringBuilder(); + sb.append("Rich Text Editor Demo"); + if (f != null) { + sb.append(" - "); + sb.append(f.getName()); + } + if (modified) { + sb.append(" *"); + } + setTitle(sb.toString()); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/SavePane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/SavePane.java new file mode 100644 index 00000000000..68fbcc99c1b --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/editor/SavePane.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.editor; + +import java.io.File; +import java.util.ArrayList; +import javafx.application.Platform; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.input.DataFormat; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.stage.DirectoryChooser; +import javafx.stage.Window; +import com.oracle.demo.rich.util.FX; + +public class SavePane extends GridPane { + private final TextField nameField; + private final ComboBox folderField; + private final ComboBox formatField; + + public SavePane() { + nameField = new TextField(); + setHgrow(nameField, Priority.ALWAYS); + setFillWidth(nameField, Boolean.TRUE); + + folderField = new ComboBox<>(); + setHgrow(folderField, Priority.ALWAYS); + setFillWidth(folderField, Boolean.TRUE); + + formatField = new ComboBox<>(); + + Button browse = new Button("Browse"); + setFillWidth(browse, Boolean.TRUE); + browse.setOnAction((ev) -> { + browse(); + }); + + int r = 0; + add(label("Save As:"), 0, r); + add(nameField, 1, r, 3, 1); + r++; + add(label("Where:"), 0, r); + add(folderField, 1, r); + add(browse, 2, r); + r++; + add(label("File Format:"), 0, r); + add(formatField, 1, r, 2, 1); + + setHgap(10); + setVgap(5); + setPadding(new Insets(10)); + + Platform.runLater(() -> { + nameField.selectAll(); + nameField.requestFocus(); + }); + } + + private static Label label(String text) { + Label t = new Label(text); + setHalignment(t, HPos.RIGHT); + return t; + } + + public void setFile(File f) { + if (f == null) { + nameField.setText("Untitled.rich"); + setDir(null); + } else { + nameField.setText(f.getName()); + setDir(f.getParentFile()); + } + } + + private void setDir(File dir) { + if (dir == null) { + dir = new File(System.getProperty("user.home")); + } + ArrayList fs = new ArrayList<>(); + File f = dir; + do { + fs.add(f); + f = f.getParentFile(); + } while (f != null); + folderField.getItems().setAll(fs); + folderField.getSelectionModel().select(dir); + } + + public void setFormat(DataFormat f) { + // TODO + } + + public File getFile() { + File dir = getDir(); + // TODO extension based on data format + return new File(dir, nameField.getText()); + } + + public DataFormat getFileFormat() { + return null; // FIX + } + + private File getDir() { + return folderField.getSelectionModel().getSelectedItem(); + } + + private void browse() { + DirectoryChooser ch = new DirectoryChooser(); + ch.setTitle("Choose Folder"); + ch.setInitialDirectory(getDir()); + Window w = FX.getParentWindow(this); + File f = ch.showDialog(w); + if (f != null) { + setDir(f); + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/Actions.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/Actions.java new file mode 100644 index 00000000000..7e26d593b88 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/Actions.java @@ -0,0 +1,807 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.input.DataFormat; +import com.oracle.demo.rich.common.Styles; +import com.oracle.demo.rich.common.TextStyle; +import com.oracle.demo.rich.notebook.data.CellInfo; +import com.oracle.demo.rich.notebook.data.Notebook; +import com.oracle.demo.rich.util.FX; +import com.oracle.demo.rich.util.FxAction; +import jfx.incubator.scene.control.rich.CodeArea; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.SelectionSegment; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.ContentChange; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * This class reacts to changes in application state such as currently active cell, + * caret, selection, model, etc.; then updates the actions disabled and selected properties. + *

+ * JavaFX has no actions (yet), so here we are using FxActions from + * https://github.com/andy-goryachev/AppFramework (with permission from the author). + */ +public class Actions { + // file + public final FxAction newDocument = new FxAction(this::newDocument); + public final FxAction open = new FxAction(this::open); + public final FxAction save = new FxAction(this::save); + // style + public final FxAction bold = new FxAction(this::bold); + public final FxAction italic = new FxAction(this::italic); + public final FxAction strikeThrough = new FxAction(this::strikeThrough); + public final FxAction underline = new FxAction(this::underline); + // editing + public final FxAction copy = new FxAction(this::copy); + public final FxAction cut = new FxAction(this::cut); + public final FxAction paste = new FxAction(this::paste); + public final FxAction pasteUnformatted = new FxAction(this::pasteUnformatted); + public final FxAction redo = new FxAction(this::redo); + public final FxAction selectAll = new FxAction(this::selectAll); + public final FxAction undo = new FxAction(this::undo); + // cells + public final FxAction copyCell = new FxAction(this::copyCell); + public final FxAction cutCell = new FxAction(this::cutCell); + public final FxAction deleteCell = new FxAction(this::deleteCell); + public final FxAction insertCellBelow = new FxAction(this::insertCellBelow); + public final FxAction mergeCellAbove = new FxAction(this::mergeCellAbove); + public final FxAction mergeCellBelow = new FxAction(this::mergeCellBelow); + public final FxAction moveCellDown = new FxAction(this::moveCellDown); + public final FxAction moveCellUp = new FxAction(this::moveCellUp); + public final FxAction pasteCellBelow = new FxAction(this::pasteCellBelow); + public final FxAction runAndAdvance = new FxAction(this::runAndAdvance); + public final FxAction runAll = new FxAction(this::runAll); + public final FxAction splitCell = new FxAction(this::splitCell); + + private enum EditorType { + CODE, + NONE, + OUTPUT, + TEXT, + } + + private final NotebookWindow window; + private final ScriptEngine engine; + private final ObservableList cellPanes = FXCollections.observableArrayList(); + private final ReadOnlyObjectWrapper activeCellPane = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyBooleanWrapper modified = new ReadOnlyBooleanWrapper(); + private final ReadOnlyObjectWrapper file = new ReadOnlyObjectWrapper<>(); + private final SimpleBooleanProperty executing = new SimpleBooleanProperty(); + private final SimpleObjectProperty editor = new SimpleObjectProperty<>(); + private final SimpleObjectProperty editorType = new SimpleObjectProperty<>(EditorType.NONE); + private final SimpleObjectProperty styles = new SimpleObjectProperty<>(); + private final SimpleObjectProperty textStyle = new SimpleObjectProperty<>(); + private final BooleanBinding disabledStyleEditing; + private int sequenceNumber; + + public Actions(NotebookWindow w) { + this.window = w; + + engine = new ScriptEngine(); + + BooleanBinding disabledEditing = Bindings.createBooleanBinding( + () -> { + if (isExecuting()) { + return true; + } + RichTextArea r = editor.get(); + if (r == null) { + return true; + } + return !r.canEdit(); + }, + editor, + executing + ); + + disabledStyleEditing = Bindings.createBooleanBinding( + () -> { + if (isExecuting()) { + return true; + } + return (editorType.get() != EditorType.TEXT); + }, + editorType, + executing + ); + + BooleanBinding cellActionsDisabled = Bindings.createBooleanBinding( + () -> { + if(isExecuting()) { + return true; + } + CellPane p = getActiveCellPane(); + return p == null; + }, + activeCellPane, + executing + ); + + BooleanBinding runDisabled = Bindings.createBooleanBinding( + () -> { + if(isExecuting()) { + return true; + } + CellType p = getActiveCellType(); + return p != CellType.CODE; + }, + activeCellPane, + executing + ); + + SimpleBooleanProperty redoDisabled = new SimpleBooleanProperty(); + SimpleBooleanProperty undoDisabled = new SimpleBooleanProperty(); + + // file actions + open.setDisabled(true); + save.setDisabled(true); + + // style actions + bold.disabledProperty().bind(disabledStyleEditing); + italic.disabledProperty().bind(disabledStyleEditing); + strikeThrough.disabledProperty().bind(disabledStyleEditing); + underline.disabledProperty().bind(disabledStyleEditing); + + // editing actions + copy.setEnabled(true); // always + cut.disabledProperty().bind(disabledEditing); + paste.disabledProperty().bind(disabledEditing); + pasteUnformatted.disabledProperty().bind(disabledEditing); + selectAll.setEnabled(true); // always + + // undo/redo actions + redo.disabledProperty().bind(redoDisabled); + undo.disabledProperty().bind(undoDisabled); + + // cell actions + copyCell.setDisabled(true); + cutCell.setDisabled(true); + deleteCell.disabledProperty().bind(cellActionsDisabled); + insertCellBelow.disabledProperty().bind(cellActionsDisabled); + mergeCellAbove.setDisabled(true); + mergeCellBelow.setDisabled(true); + moveCellDown.disabledProperty().bind(cellActionsDisabled); + moveCellUp.disabledProperty().bind(cellActionsDisabled); + pasteCellBelow.setDisabled(true); + runAndAdvance.disabledProperty().bind(runDisabled); + runAll.setDisabled(true); + splitCell.disabledProperty().bind(disabledEditing); + + // listeners + + styles.addListener((s,p,a) -> { + bold.setSelected(hasStyle(a, StyleAttrs.BOLD), false); + italic.setSelected(hasStyle(a, StyleAttrs.ITALIC), false); + strikeThrough.setSelected(hasStyle(a, StyleAttrs.STRIKE_THROUGH), false); + underline.setSelected(hasStyle(a, StyleAttrs.UNDERLINE), false); + }); + + ChangeListener focusOwnerListener = (src, old, node) -> { + CellPane p = FX.findParentOf(CellPane.class, node); + if (p == null) { + return; + } + + RichTextArea r = FX.findParentOf(RichTextArea.class, node); + editor.set(r); + + EditorType t = getEditorType(r); + editorType.set(t); + updateSourceStyles(); + }; + + window.sceneProperty().addListener((src, old, cur) -> { + if(old != null) { + old.focusOwnerProperty().removeListener(focusOwnerListener); + } + if(cur != null) { + cur.focusOwnerProperty().addListener(focusOwnerListener); + } + }); + + StyledTextModel.Listener changeListener = new StyledTextModel.Listener() { + @Override + public void onContentChange(ContentChange ch) { + if (ch.isEdit()) { + handleEdit(); + } else { + if (editorType.get() == EditorType.TEXT) { + handleEdit(); + } + } + } + }; + + InvalidationListener selectionListener = (p) -> { + updateSourceStyles(); + }; + + editor.addListener((src, old, ed) -> { + if (old != null) { + if (isSourceEditor(old)) { + old.getModel().removeChangeListener(changeListener); + old.selectionProperty().removeListener(selectionListener); + } + } + + redoDisabled.unbind(); + redoDisabled.set(true); + undoDisabled.unbind(); + undoDisabled.set(true); + + if (ed != null) { + if (isSourceEditor(ed)) { + ed.getModel().addChangeListener(changeListener); + ed.selectionProperty().addListener(selectionListener); + redoDisabled.bind(executing.or(ed.redoableProperty().not())); + undoDisabled.bind(executing.or(ed.undoableProperty().not())); + } + } + }); + + updateSourceStyles(); + + activeCellPane.addListener((src, prev, cur) -> { + if (prev != null) { + prev.setActive(false); + } + if (cur != null) { + cur.setActive(true); + } + }); + } + + private EditorType getEditorType(RichTextArea r) { + if (r == null) { + return EditorType.NONE; + } else if (r instanceof CodeArea) { + if (r.canEdit()) { + return EditorType.CODE; + } else { + return EditorType.OUTPUT; + } + } + return EditorType.TEXT; + } + + private boolean isSourceEditor(RichTextArea r) { + EditorType t = getEditorType(r); + switch (t) { + case CODE: + case TEXT: + return true; + } + return false; + } + + private void updateSourceStyles() { + StyleAttrs a = getSourceStyleAttrs(); + if (a != null) { + styles.set(a); + + TextStyle st = Styles.guessTextStyle(a); + textStyle.set(st); + } + } + + public final ObjectProperty textStyleProperty() { + return textStyle; + } + + private StyleAttrs getSourceStyleAttrs() { + RichTextArea r = editor.get(); + EditorType t = getEditorType(r); + switch (t) { + case TEXT: + SelectionSegment sel = r.getSelection(); + if ((sel == null) || (!sel.isCollapsed())) { + return null; + } + return r.getActiveStyleAttrs(); + } + return null; + } + + private boolean hasStyle(StyleAttrs attrs, StyleAttribute a) { + return attrs == null ? false : Boolean.TRUE.equals(attrs.get(a)); + } + + private final boolean isExecuting() { + return executing.get(); + } + + private void setExecuting(boolean on) { + executing.set(on); + } + + public final ReadOnlyBooleanProperty modifiedProperty() { + return modified.getReadOnlyProperty(); + } + + public final boolean isModified() { + return modified.get(); + } + + private void setModified(boolean on) { + modified.set(on); + } + + public final ReadOnlyObjectProperty fileNameProperty() { + return file.getReadOnlyProperty(); + } + + public final File getFile() { + return file.get(); + } + + private void handleEdit() { + setModified(true); + } + + public void newDocument() { +// if (askToSave()) { +// return; +// } + Notebook n = new Notebook(); + n.add(new CellInfo(CellType.CODE)); + window.setNotebook(n); + } + + private void open() { +// if (askToSave()) { +// return; +// } +// +// FileChooser ch = new FileChooser(); +// ch.setTitle("Open File"); +// // TODO add extensions +// Window w = FX.getParentWindow(control); +// File f = ch.showOpenDialog(w); +// if (f != null) { +// try { +// readFile(f, RichTextFormatHandler.DATA_FORMAT); +// } catch (Exception e) { +// new ExceptionDialog(control, e).open(); +// } +// } + } + + // FIX this is too simplistic, need save() and save as... + private void save() { +// File f = getFile(); +// if (f == null) { +// FileChooser ch = new FileChooser(); +// ch.setTitle("Save File"); +// // TODO add extensions +// Window w = FX.getParentWindow(control); +// f = ch.showSaveDialog(w); +// if (f == null) { +// return; +// } +// } +// try { +// writeFile(f, RichTextFormatHandler.DATA_FORMAT); +// } catch (Exception e) { +// new ExceptionDialog(control, e).open(); +// } + } + + // returns true if the user chose to Cancel + private boolean askToSave() { +// if (isModified()) { +// // alert: has been modified. do you want to save? +// Alert alert = new Alert(AlertType.CONFIRMATION); +// alert.initOwner(FX.getParentWindow(control)); +// alert.setTitle("Document is Modified"); +// alert.setHeaderText("Do you want to save this document?"); +// ButtonType delete = new ButtonType("Delete"); +// ButtonType cancel = new ButtonType("Cancel", ButtonData.CANCEL_CLOSE); +// ButtonType save = new ButtonType("Save", ButtonData.APPLY); +// alert.getButtonTypes().setAll( +// delete, +// cancel, +// save +// ); +// +// File f = getFile(); +// // FIX format selector is not needed! +// SavePane sp = new SavePane(); +// sp.setFile(f); +// alert.getDialogPane().setContent(sp); +// Optional result = alert.showAndWait(); +// if (result.isPresent()) { +// ButtonType t = result.get(); +// if (t == delete) { +// return false; +// } else if (t == cancel) { +// return true; +// } else { +// // save using info in the panel +// f = sp.getFile(); +// DataFormat fmt = sp.getFileFormat(); +// // FIX +// fmt = RichTextFormatHandler.DATA_FORMAT; +// +// try { +// writeFile(f, fmt); +// } catch (Exception e) { +// new ExceptionDialog(control, e).open(); +// return true; +// } +// } +// } else { +// return true; +// } +// } + return false; + } + + private void readFile(File f, DataFormat fmt) throws Exception { +// try (FileInputStream in = new FileInputStream(f)) { +// control.read(fmt, in); +// file.set(f); +// modified.set(false); +// } + } + + private void writeFile(File f, DataFormat fmt) throws Exception { +// try (FileOutputStream out = new FileOutputStream(f)) { +// control.write(fmt, out); +// file.set(f); +// modified.set(false); +// } + } + + private void runAll() { + // TODO + } + + public void runAndAdvance() { + CellPane p = getActiveCellPane(); + if (p != null) { + CellInfo cell = p.getCellInfo(); + if (cell.isCode()) { + String src = cell.getSource(); + runScript(p, src, true); + } + } + } + + private void runScript(CellPane p, String src, boolean advance) { + setExecuting(true); + p.setExecuting(); + + Thread t = new Thread("executing script [" + (sequenceNumber + 1) + "]") { + @Override + public void run() { + Object r; + try { + r = engine.executeScript(src); + } catch (Throwable e) { + r = e; + } + handleCompletion(p, r, advance); + } + }; + t.setPriority(Thread.MIN_PRIORITY); + t.start(); + } + + /** this method is called from a background thread */ + private void handleCompletion(CellPane p, Object result, boolean advance) { + Platform.runLater(() -> { + setExecuting(false); + p.setResult(result, ++sequenceNumber); + if (advance) { + int ix = getActiveCellIndex(); + ix++; + if (ix < cellPanes.size()) { + CellPane next = cellPanes.get(ix); + setActiveCellPane(next); + next.focusLater(); + } + } + }); + } + + public final ObservableList getCellPanes() { + return cellPanes; + } + + public final void setActiveCellPane(CellPane p) { + activeCellPane.set(p); + } + + public final CellPane getActiveCellPane() { + return activeCellPane.get(); + } + + private CellType getActiveCellType() { + CellPane p = getActiveCellPane(); + return (p == null ? null : p.getCellType()); + } + + private RichTextArea getSourceEditor() { + CellPane p = getActiveCellPane(); + return (p == null ? null : p.getSourceEditor()); + } + + public void setNotebook(Notebook b) { + int sz = b == null ? 0 : b.size(); + ArrayList ps = new ArrayList<>(sz); + if (b != null) { + for (int i = 0; i < sz; i++) { + CellInfo cell = b.getCell(i); + ps.add(new CellPane(cell)); + } + } + cellPanes.setAll(ps); + setModified(false); + } + + public void copy() { + whenCell((t) -> t.copy()); + } + + public void cut() { + whenCell((t) -> t.cut()); + } + + public void paste() { + whenCell((t) -> t.paste()); + } + + public void pasteUnformatted() { + whenCell((t) -> t.pastePlainText()); + } + + private void whenCell(Consumer c) { + whenCell(null, c); + } + + private void whenCell(CellType type, Consumer c) { + CellPane p = getActiveCellPane(); + if (p != null) { + if (type != null) { + if (type != p.getCellType()) { + return; + } + } + RichTextArea r = p.getSourceEditor(); + if (r != null) { + c.accept(r); + } + } + } + + public int getActiveCellIndex() { + CellPane p = getActiveCellPane(); + return cellPanes.indexOf(p); + } + + public void insertCellBelow() { + int ix = getActiveCellIndex(); + if (ix < 0) { + ix = 0; + } + CellInfo cell = new CellInfo(CellType.CODE); + CellPane p = new CellPane(cell); + add(ix + 1, p); + p.focusLater(); + } + + public void moveCellDown() { + int ix = getActiveCellIndex(); + if (ix >= 0) { + if (ix + 1 < cellPanes.size()) { + CellPane p = cellPanes.remove(ix); + add(ix + 1, p); + } + } + } + + public void moveCellUp() { + int ix = getActiveCellIndex(); + if (ix > 0) { + CellPane p = cellPanes.remove(ix); + add(ix - 1, p); + } + } + + private void add(int ix, CellPane p) { + if (ix < cellPanes.size()) { + cellPanes.add(ix, p); + } else { + cellPanes.add(p); + } + } + + public void deleteCell() { + if (cellPanes.size() > 1) { + int ix = getActiveCellIndex(); + if (ix >= 0) { + cellPanes.remove(ix); + } + } + } + + public void selectAll() { + whenCell((c) -> { + c.selectAll(); + }); + } + + public void redo() { + whenCell((c) -> { + c.redo(); + }); + } + + public void undo() { + whenCell((c) -> { + c.undo(); + }); + } + + public void bold() { + toggleStyle(StyleAttrs.BOLD); + } + + public void italic() { + toggleStyle(StyleAttrs.ITALIC); + } + + public void strikeThrough() { + toggleStyle(StyleAttrs.STRIKE_THROUGH); + } + + public void underline() { + toggleStyle(StyleAttrs.UNDERLINE); + } + + private void toggleStyle(StyleAttribute attr) { + whenCell(CellType.TEXT, (c) -> { + TextPos start = c.getAnchorPosition(); + TextPos end = c.getCaretPosition(); + if (start == null) { + return; + } else if (start.equals(end)) { + // apply to the whole paragraph + int ix = start.index(); + start = new TextPos(ix, 0); + end = c.getParagraphEnd(ix); + } + + StyleAttrs a = c.getActiveStyleAttrs(); + boolean on = !a.getBoolean(attr); + a = StyleAttrs.builder().set(attr, on).build(); + c.applyStyle(start, end, a); + updateSourceStyles(); + }); + } + + public void setTextStyle(TextStyle st) { + whenCell(CellType.TEXT, (c) -> { + TextPos start = c.getAnchorPosition(); + TextPos end = c.getCaretPosition(); + if (start == null) { + return; + } else if (start.equals(end)) { + TextStyle cur = Styles.guessTextStyle(c.getActiveStyleAttrs()); + if (cur == st) { + return; + } + // apply to the whole paragraph + int ix = start.index(); + start = new TextPos(ix, 0); + end = c.getParagraphEnd(ix); + } + + StyleAttrs a = Styles.getStyleAttrs(st); + c.applyStyle(start, end, a); + updateSourceStyles(); + }); + } + + public void setActiveCellType(CellType t) { + if (t != null) { + CellPane p = getActiveCellPane(); + int ix = cellPanes.indexOf(p); + if (ix >= 0) { + CellInfo cell = p.getCellInfo(); + if (t != cell.getCellType()) { + cell.setCellType(t); + p = new CellPane(cell); + cellPanes.set(ix, p); + p.focusLater(); + } + } + } + } + + private void copyCell() { + // TODO + } + + private void cutCell() { + // TODO + } + + private void mergeCellAbove() { + // TODO + } + + private void mergeCellBelow() { + // TODO + } + + private void pasteCellBelow() { + // TODO + } + + private void splitCell() { + whenCell((c) -> { + int ix = getActiveCellIndex(); + if(ix < 0) { + return; + } + CellPane p = cellPanes.get(ix); + List ps = p.split(); + if(ps == null) { + return; + } + cellPanes.remove(ix); + cellPanes.addAll(ix, ps); + }); + } + + public BooleanBinding disabledStyleEditingProperty() { + return disabledStyleEditing; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellContainer.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellContainer.java new file mode 100644 index 00000000000..8a0da01115c --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellContainer.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import javafx.geometry.Insets; +import javafx.scene.layout.VBox; +import com.oracle.demo.rich.notebook.data.Notebook; + +/** + * Cell Container. + */ +public class CellContainer extends VBox { + private Notebook notebook; + + public CellContainer() { + setSpacing(3); + setPadding(new Insets(3)); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellPane.java new file mode 100644 index 00000000000..b437c8415ca --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellPane.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.geometry.VPos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.text.Font; +import com.oracle.demo.rich.codearea.JavaSyntaxDecorator; +import com.oracle.demo.rich.notebook.data.CellInfo; +import com.oracle.demo.rich.util.FX; +import jfx.incubator.scene.control.rich.CodeArea; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.SegmentBuffer; + +/** + * Pane holds the visuals for the cell: source editor, output pane, execution label, current cell highlight. + */ +public class CellPane extends GridPane { + private static final PseudoClass EXECUTING = PseudoClass.getPseudoClass("executing"); + private static final Font FONT = new Font("Iosevka Fixed SS16", 12); + private static final Insets OUTPUT_PADDING = new Insets(0, 0, 3, 0); + private final CellInfo cell; + private final Region codeBar; + private final Label execLabel; + private final BorderPane sourcePane; + private final BorderPane outputPane; + private final SimpleBooleanProperty active = new SimpleBooleanProperty(); + + // TODO the side bar and exec label turn orange when source has been edited and the old output is still present + // also exec label shows an asterisk *[N] + public CellPane(CellInfo c) { + super(3, 0); + + this.cell = c; + FX.style(this, "cell-pane"); + + codeBar = new Region(); + codeBar.setMinWidth(6); + codeBar.setMaxWidth(6); + FX.style(codeBar, "code-bar"); + + execLabel = new Label(cell.isCode() ? "[ ]:" : null); + execLabel.setAlignment(Pos.TOP_RIGHT); + execLabel.setMinWidth(50); + FX.style(execLabel, "exec-label"); + // TODO bind to font property, set preferred width + setValignment(execLabel, VPos.TOP); + + sourcePane = new BorderPane(); + setHgrow(sourcePane, Priority.ALWAYS); + setVgrow(sourcePane, Priority.NEVER); + + outputPane = new BorderPane(); + FX.style(outputPane, "output-pane"); + outputPane.setMaxHeight(200); + setHgrow(outputPane, Priority.ALWAYS); + setVgrow(outputPane, Priority.NEVER); + setMargin(outputPane, OUTPUT_PADDING); + + int r = 0; + add(codeBar, 0, r, 1, 2); + add(execLabel, 1, r); + add(sourcePane, 2, r); + r++; + add(outputPane, 2, r); + + updateContent(); + + active.addListener((s,p,v) -> { + FX.style(this, "active-cell", v); + }); + } + + private void updateContent() { + RichTextArea ed = createEditor(); + sourcePane.setCenter(ed); + outputPane.setCenter(null); + ed.applyCss(); + } + + private RichTextArea createEditor() { + CellType t = cell.getCellType(); + switch (t) { + case CODE: + CodeArea c = new CodeArea(); + FX.style(c, "code-cell"); + c.setFont(FONT); + c.setModel(cell.getModel()); + c.setSyntaxDecorator(new JavaSyntaxDecorator()); + c.setUseContentHeight(true); + c.setWrapText(true); + return c; + case TEXT: + RichTextArea r = new RichTextArea(); + FX.style(r, "text-cell"); + r.setModel(cell.getModel()); + r.setUseContentHeight(true); + r.setWrapText(true); + return r; + } + return null; + } + + public final CellInfo getCellInfo() { + return cell; + } + + public final CellType getCellType() { + return cell.getCellType(); + } + + public void setExecuting() { + execLabel.setText("[*]:"); + FX.style(execLabel, EXECUTING, true); + + getSourceEditor().requestFocus(); + outputPane.setCenter(null); + } + + public void setResult(Object result, int execCount) { + String s = (execCount <= 0) ? " " : String.valueOf(execCount); + execLabel.setText("[" + s + "]:"); + FX.style(execLabel, EXECUTING, false); + + Node n = createResultNode(result); + outputPane.setCenter(n); + } + + private Node createResultNode(Object result) { + if(result != null) { + if(result instanceof Supplier gen) { + Object v = gen.get(); + if(v instanceof Node n) { + return n; + } + } else if(result instanceof Throwable err) { + StringWriter sw = new StringWriter(); + PrintWriter wr = new PrintWriter(sw); + err.printStackTrace(wr); + String text = sw.toString(); + return textViewer(text, true); + } else if(result instanceof Image im) { + ImageView v = new ImageView(im); + ScrollPane sp = new ScrollPane(v); + FX.style(sp, "image-result"); + return sp; + } else if(result instanceof CodeTextModel m) { + CodeArea t = new CodeArea(m); + t.setMinHeight(300); + t.setSyntaxDecorator(new SimpleJsonDecorator()); + t.setFont(FONT); + t.setWrapText(false); + t.setEditable(false); + t.setLineNumbersEnabled(true); + FX.style(t, "output-text"); + return t; + } else { + String text = result.toString(); + return textViewer(text, false); + } + } + return null; + } + + private static CodeTextModel from(String text) throws IOException { + CodeTextModel m = new CodeTextModel(); + m.insertText(TextPos.ZERO, text); + return m; + } + + private Node textViewer(String text, boolean error) { + try { + CodeTextModel m = from(text); + + CodeArea t = new CodeArea(); + t.setFont(FONT); + t.setModel(m); + t.setUseContentHeight(true); + t.setWrapText(false); + t.setEditable(false); + FX.style(t, error ? "output-error" : "output-text"); + return t; + } catch (IOException wontHappen) { + return null; + } + } + + // FIX does not work! + public void focusLater() { + Platform.runLater(() -> { + Node n = sourcePane.getCenter(); + if (n instanceof RichTextArea a) { + a.requestFocus(); + } + }); + } + + public RichTextArea getSourceEditor() { + Node n = sourcePane.getCenter(); + if (n instanceof RichTextArea r) { + return r; + } + return null; + } + + /** + * Splits the cell at the current source editor caret position. + * TODO split into three parts when non-empty selection exists. + * @return the list of cells resulting from the split + */ + public List split() { + RichTextArea r = getSourceEditor(); + if (r != null) { + TextPos start = r.getAnchorPosition(); + if (start != null) { + TextPos end = r.getCaretPosition(); + if (start.equals(end)) { + return splitInTwo(start); + } else { + // TODO split into 3 parts? + } + } + } + return null; + } + + private List splitInTwo(TextPos p) { + RichTextArea ed = getSourceEditor(); + CellType t = getCellType(); + + try { + CellPane cell1 = new CellPane(new CellInfo(t)); + insert(ed, TextPos.ZERO, p, cell1.getSourceEditor(), TextPos.ZERO); + + CellPane cell2 = new CellPane(new CellInfo(t)); + insert(ed, p, ed.getDocumentEnd(), cell2.getSourceEditor(), TextPos.ZERO); + + return List.of(cell1, cell2); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + private void insert(RichTextArea src, TextPos start, TextPos end, RichTextArea tgt, TextPos pos) throws IOException { + SegmentBuffer b = new SegmentBuffer(); + src.getModel().export(start, end, b.getStyledOutput()); + tgt.insertText(pos, b.getStyledInput()); + } + + public void setActive(boolean on) { + active.set(on); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellType.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellType.java new file mode 100644 index 00000000000..d7af1e31660 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CellType.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import javafx.util.StringConverter; + +/** + * Cell type enum. + */ +public enum CellType { + CODE, + TEXT; + + public String getDisplayName() { + switch(this) { + case CODE: + return "Code"; + case TEXT: + return "Text"; + } + throw new Error("?" + this); + } + + public static StringConverter converter() { + return new StringConverter() { + @Override + public String toString(CellType t) { + return t == null ? null : t.getDisplayName(); + } + + @Override + public CellType fromString(String s) { + for (CellType t : CellType.values()) { + if (s.equals(t.getDisplayName())) { + return t; + } + } + return CellType.TEXT; + } + }; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CodeCellTextModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CodeCellTextModel.java new file mode 100644 index 00000000000..577e5eacbf1 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/CodeCellTextModel.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.io.IOException; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.ContentChange; +import jfx.incubator.scene.control.rich.model.StyledOutput; + +/** + * Cell Text Model. + */ +public class CodeCellTextModel extends CodeTextModel { + private boolean modified; + + public CodeCellTextModel() { + addChangeListener(new Listener() { + @Override + public void onContentChange(ContentChange ch) { + if (ch.isEdit()) { + setModified(true); + } + } + }); + } + + public boolean isModified() { + return modified; + } + + public void setModified(boolean on) { + modified = on; + } + + public void setText(String text) { + replace(null, TextPos.ZERO, TextPos.ZERO, text, false); + setModified(false); + } + + public String getText() { + try { + StyledOutput out = StyledOutput.forPlainText(); + TextPos end = getDocumentEnd(); + export(TextPos.ZERO, end, out); + return out.toString(); + } catch (IOException e) { + return null; + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/Demo.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/Demo.java new file mode 100644 index 00000000000..fec1956ef40 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/Demo.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import com.oracle.demo.rich.notebook.data.CellInfo; +import com.oracle.demo.rich.notebook.data.Notebook; + +public class Demo { + public static Notebook createNotebookExample() { + Notebook b = new Notebook(); + { + CellInfo c = new CellInfo(CellType.TEXT); + c.setSource( + """ + Notebook Interface + + A notebook interface or computational notebook is a virtual notebook environment used for literate programming, a method of writing computer programs. Some notebooks are WYSIWYG environments including executable calculations embedded in formatted documents; others separate calculations and text into separate sections. Notebooks share some goals and features with spreadsheets and word processors but go beyond their limited data models. + + Modular notebooks may connect to a variety of computational back ends, called "kernels". Notebook interfaces are widely used for statistics, data science, machine learning, and computer algebra. + + https://en.wikipedia.org/wiki/Notebook_interface"""); + b.add(c); + } + { + CellInfo c = new CellInfo(CellType.CODE); + c.setSource( + """ + /** + * This code cell generates a multi-line text result. + */ + int x = 5; + String text = "text"; + print(x);"""); + b.add(c); + } + { + CellInfo c = new CellInfo(CellType.CODE); + c.setSource( + """ + // + // This code cell generates a general failure (exception) + // + double sin(double x) { + return Math.sin(x); + } + print(sin(x) + 5.0);"""); + b.add(c); + } + { + CellInfo c = new CellInfo(CellType.CODE); + c.setSource( + """ + // + // This code cell generates an image output + // + display(image);"""); + b.add(c); + } + { + CellInfo c = new CellInfo(CellType.CODE); + c.setSource( + """ + // And finally, this code cell generates a Node output. + // This way any complex result can be rendered: a chart, a table or a spreadsheet, a complex input form... + // + var node = new ListView(data); + render(node);"""); + b.add(c); + } + { + CellInfo c = new CellInfo(CellType.CODE); + c.setSource( + """ + // This example simulates a JSON output backed by an external source, such as + // database or remote API call. + json = generateJsonOutput();"""); + b.add(c); + } + return b; + } + + public static Notebook createSingleTextCell() { + Notebook b = new Notebook(); + { + CellInfo c = new CellInfo(CellType.TEXT); + c.setSource( + """ + This is a text cell. + Right now it is a plain text cell, but we can make it a rich text cell. + The only problem is that the user can change the cell type - and changing it from rich text to + code or any other plain text based types will remove the styles. + We could, of course, save the rich text until the user modifies the text, or may be even preserve + the style information by simply rendering the plain text paragraphs, but then what would happen if + the user switches back to rich text after editing? Worth the try."""); + b.add(c); + } + return b; + } + + public static Notebook createSingleCodeCell() { + Notebook b = new Notebook(); + { + CellInfo c = new CellInfo(CellType.CODE); + b.add(c); + } + return b; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/ExceptionDialog.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/ExceptionDialog.java new file mode 100644 index 00000000000..2cf0c9e77b4 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/ExceptionDialog.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.io.FileNotFoundException; +import java.io.PrintWriter; +import java.io.StringWriter; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class ExceptionDialog extends Alert { + public ExceptionDialog(Node owner, Throwable err) { + super(AlertType.ERROR); + + setTitle("An Error Occurred"); + //setHeaderText(""); + //setContentText(""); + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + err.printStackTrace(pw); + String text = sw.toString(); + + Label label = new Label("The exception stacktrace:"); + + TextArea textArea = new TextArea(text); + textArea.setEditable(false); + textArea.setWrapText(false); + + textArea.setMaxWidth(Double.MAX_VALUE); + textArea.setMaxHeight(Double.MAX_VALUE); + GridPane.setVgrow(textArea, Priority.ALWAYS); + GridPane.setHgrow(textArea, Priority.ALWAYS); + + GridPane expContent = new GridPane(); + expContent.setMaxWidth(Double.MAX_VALUE); + expContent.add(label, 0, 0); + expContent.add(textArea, 0, 1); + + getDialogPane().setExpandableContent(expContent); + } + + public void open() { + showAndWait(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/JsonContentWithAsyncUpdate.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/JsonContentWithAsyncUpdate.java new file mode 100644 index 00000000000..3dcbde2cd13 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/JsonContentWithAsyncUpdate.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.text.SimpleDateFormat; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Random; +import java.util.function.Consumer; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.util.Duration; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.PlainTextModel; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Mock content which simulates non-instantaneous retrieval of the underlying data, + * as in database call or remote file system. + */ +public class JsonContentWithAsyncUpdate implements PlainTextModel.Content { + private final int size; + private final HashMap data; + private final Random random = new Random(); + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss.SSSS"); + private final HexFormat hex = HexFormat.of(); + private Consumer updater; + + public JsonContentWithAsyncUpdate(int size) { + this.size = size; + this.data = new HashMap<>(size); + } + + @Override + public boolean isUserEditable() { + return true; + } + + @Override + public int size() { + return size; + } + + @Override + public String getText(int index) { + String s = data.get(index); + if (s == null) { + queue(index); + return ""; + } + return s; + } + + private void queue(int index) { + Duration simulatedDelay = Duration.millis(200 + random.nextInt(3_000)); + Timeline t = new Timeline(); + t.setCycleCount(1); + t.getKeyFrames().add( + new KeyFrame(simulatedDelay, (ev) -> { + String s = generate(index); + if(!data.containsKey(index)) { + data.put(index, s); + update(index); + } + }) + ); + t.play(); + } + + private void update(int index) { + if (updater != null) { + updater.accept(index); + } + } + + public void setUpdater(Consumer u) { + updater = u; + } + + @Override + public int insertTextSegment(int index, int offset, String text, StyleAttrs attrs) { + throw new UnsupportedOperationException(); + } + + @Override + public void insertLineBreak(int index, int offset) { + throw new UnsupportedOperationException(); + } + + @Override + public void removeRange(TextPos start, TextPos end) { + throw new UnsupportedOperationException(); + } + + private String bytes(int count) { + byte[] b = new byte[count]; + random.nextBytes(b); + return hex.formatHex(b); + } + + private String generate(int index) { + Random r = new Random(); + long time = System.currentTimeMillis() - ((size - 1 - index) * 145_678L); + String date = dateFormat.format(time); + String id = bytes(8); + String message = bytes(1 + random.nextInt(10)); + String payload = bytes(10 + random.nextInt(128)); + int size = payload.length() / 2; + + return + "{date=\"" + date + "\"" + + ", timestamp=" + time + + ", id=\"" + id + "\"" + + ", message-id=\"" + message + "\"" + + ", payload=\"" + payload + "\"" + + ", size=" + size + + "}"; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookMockupApp.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookMockupApp.java new file mode 100644 index 00000000000..02f7cc67e6e --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookMockupApp.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.notebook; + +import javafx.application.Application; +import javafx.stage.Stage; +import com.oracle.demo.rich.settings.FxSettings; + +/** + * Interactive Notebook Skeleton Implementation. + * Demonstrates the use of RichTextArea/CodeArea in a notebook-like setting. + */ +public class NotebookMockupApp extends Application { + public static void main(String[] args) { + Application.launch(NotebookMockupApp.class, args); + } + + @Override + public void init() { + FxSettings.useDirectory(".NotebookMockupApp"); + } + + @Override + public void start(Stage stage) throws Exception { + new NotebookWindow().show(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookPane.java new file mode 100644 index 00000000000..01f20356e58 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookPane.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.notebook; + +import javafx.beans.binding.Bindings; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.ToolBar; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.BorderPane; +import com.oracle.demo.rich.common.TextStyle; +import com.oracle.demo.rich.util.FX; + +/** + * Main Panel. + */ +public class NotebookPane extends BorderPane { + public final CellContainer cellContainer; + private final Actions actions; + private final ComboBox cellType; + private final ComboBox textStyle; + + public NotebookPane(Actions a) { + FX.name(this, "RichEditorDemoPane"); + + this.actions = a; + + cellContainer = new CellContainer(); + Bindings.bindContent(cellContainer.getChildren(), actions.getCellPanes()); + //cellPane.setContextMenu(createContextMenu()); + // this is a job for the InputMap! + cellContainer.addEventFilter(KeyEvent.KEY_PRESSED, this::handleContextExecute); + + cellType = new ComboBox<>(); + cellType.getItems().setAll(CellType.values()); + cellType.setConverter(CellType.converter()); + cellType.setOnAction((ev) -> { + updateActiveCellType(); + }); + + textStyle = new ComboBox<>(); + textStyle.getItems().setAll(TextStyle.values()); + textStyle.setConverter(TextStyle.converter()); + textStyle.setOnAction((ev) -> { + updateTextStyle(); + }); + textStyle.disableProperty().bind(actions.disabledStyleEditingProperty()); + + ScrollPane scroll = new ScrollPane(cellContainer); + scroll.setFitToWidth(true); + + setTop(createToolBar()); + setCenter(scroll); + + actions.textStyleProperty().addListener((s,p,c) -> { + setTextStyle(c); + }); + } + + // TODO move to window? + private ToolBar createToolBar() { + ToolBar t = new ToolBar(); + FX.button(t, "+", "Insert a cell below", actions.insertCellBelow); + FX.button(t, "Cu", "Cut this cell"); + FX.button(t, "Co", "Copy this cell"); + FX.button(t, "Pa", "Paste this cell from the clipboard"); + FX.add(t, cellType); + FX.space(t); + FX.button(t, "▶", "Run this cell and advance", actions.runAndAdvance); + FX.button(t, "▶▶", "Run all cells", actions.runAll); + FX.space(t); + FX.toggleButton(t, "𝐁", "Bold text", actions.bold); + FX.toggleButton(t, "𝐼", "Bold text", actions.italic); + FX.toggleButton(t, "S\u0336", "Strike through text", actions.strikeThrough); + FX.toggleButton(t, "U\u0332", "Underline text", actions.underline); + FX.add(t, textStyle); + return t; + } + + // TODO use this? + private ContextMenu createContextMenu() { + ContextMenu m = new ContextMenu(); + FX.item(m, "Cut Cell"); + FX.item(m, "Copy Cell"); + FX.item(m, "Paste Cell Below"); + FX.separator(m); + FX.item(m, "Delete Cell"); + FX.separator(m); + FX.item(m, "Split Cell"); + FX.item(m, "Merge Selected Cell"); + FX.item(m, "Merge Cell Above"); + FX.item(m, "Merge Cell Below"); + FX.separator(m); + FX.item(m, "Undo", actions.undo); + FX.item(m, "Redo", actions.redo); + FX.separator(m); + FX.item(m, "Cut", actions.cut); + FX.item(m, "Copy", actions.copy); + FX.item(m, "Paste", actions.paste); + FX.item(m, "Paste and Retain Style", actions.pasteUnformatted); + FX.separator(m); + FX.item(m, "Select All", actions.selectAll); + return m; + } + + public void setActiveCellPane(CellPane p) { + CellType t = (p == null ? null : p.getCellType()); + cellType.getSelectionModel().select(t); + } + + private void updateActiveCellType() { + CellType t = cellType.getSelectionModel().getSelectedItem(); + actions.setActiveCellType(t); + } + + private void handleContextExecute(KeyEvent ev) { + if (ev.getCode() == KeyCode.ENTER) { + if (ev.isShortcutDown()) { + actions.runAndAdvance(); + } + } + } + + private void updateTextStyle() { + TextStyle st = textStyle.getSelectionModel().getSelectedItem(); + if (st != null) { + actions.setTextStyle(st); + } + } + + public void setTextStyle(TextStyle v) { + textStyle.setValue(v); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookWindow.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookWindow.java new file mode 100644 index 00000000000..1b10971d3ef --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/NotebookWindow.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.notebook; + +import java.io.File; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; +import com.oracle.demo.rich.notebook.data.Notebook; +import com.oracle.demo.rich.rta.RichTextAreaWindow; +import com.oracle.demo.rich.util.FX; + +/** + * Notebook Demo main window. + */ +public class NotebookWindow extends Stage { + private static final String TITLE = "Interactive Notebook (Mockup)"; + private final Actions actions; + private final NotebookPane pane; + private final Label status; + + public NotebookWindow() { + FX.name(this, "NotebookWindow"); + + actions = new Actions(this); + + pane = new NotebookPane(actions); + + status = new Label(); + status.setPadding(new Insets(2, 10, 2, 10)); + + BorderPane bp = new BorderPane(); + bp.setTop(createMenu()); + bp.setCenter(pane); + bp.setBottom(status); + + Scene scene = new Scene(bp); + scene.getStylesheets().addAll( + getClass().getResource("notebook.css").toExternalForm() + ); + scene.focusOwnerProperty().addListener((s,p,c) -> { + handleFocusUpdate(c); + }); + + // TODO input map for the window: add shortcut-S for saving + + setScene(scene); + setWidth(1200); + setHeight(600); + + actions.modifiedProperty().addListener((x) -> { + updateTitle(); + }); + actions.fileNameProperty().addListener((x) -> { + updateTitle(); + }); + updateTitle(); + + setNotebook(Demo.createSingleCodeCell()); + //setNotebook(Demo.createNotebookExample()); + } + + private MenuBar createMenu() { + Menu m2; + MenuBar b = new MenuBar(); + // file + FX.menu(b, "File"); + FX.item(b, "New", actions.newDocument); + FX.item(b, "Open...", actions.open); + m2 = FX.submenu(b, "Open Recent"); + FX.item(m2, "Notebook Example", () -> setNotebook(Demo.createNotebookExample())); + FX.item(m2, "Single Text Cell", () -> setNotebook(Demo.createSingleTextCell())); + FX.item(m2, "Empty Code Cell", () -> setNotebook(Demo.createSingleCodeCell())); + FX.separator(b); + FX.item(b, "Save...", actions.save); + // TODO print? + FX.item(b, "Quit", () -> Platform.exit()); + + // edit + FX.menu(b, "Edit"); + FX.item(b, "Undo", actions.undo); + FX.item(b, "Redo", actions.redo); + FX.separator(b); + FX.item(b, "Cut", actions.cut); + FX.item(b, "Copy", actions.copy); + FX.item(b, "Paste", actions.paste); + FX.item(b, "Paste and Retain Style", actions.pasteUnformatted); + + // format + FX.menu(b, "Format"); + FX.checkItem(b, "Bold", actions.bold); + FX.checkItem(b, "Italic", actions.italic); + FX.checkItem(b, "Strike Through", actions.strikeThrough); + FX.checkItem(b, "Underline", actions.underline); + + // cell + FX.menu(b, "Cell"); + FX.item(b, "Cut Cell", actions.cutCell); + FX.item(b, "Copy Cell", actions.copyCell); + FX.item(b, "Paste Cell Below", actions.pasteCellBelow); + FX.separator(b); + FX.item(b, "Insert Cell Below", actions.insertCellBelow); + FX.separator(b); + FX.item(b, "Move Up", actions.moveCellUp); + FX.item(b, "Move Down", actions.moveCellDown); + FX.separator(b); + FX.item(b, "Split Cell", actions.splitCell); + FX.item(b, "Merge Cell Above", actions.mergeCellAbove); + FX.item(b, "Merge Cell Below", actions.mergeCellBelow); + FX.separator(b); + FX.item(b, "Delete", actions.deleteCell); + + // run + FX.menu(b, "Run"); + FX.item(b, "Run Current Cell And Advance", actions.runAndAdvance); + FX.item(b, "Run All Cells", actions.runAll); + + // view + FX.menu(b, "View"); + FX.item(b, "Show Line Numbers"); + + // help + FX.menu(b, "Help"); + FX.item(b, "About"); + return b; + } + + private void updateTitle() { + File f = actions.getFile(); + boolean modified = actions.isModified(); + + StringBuilder sb = new StringBuilder(); + sb.append(TITLE); + if (f != null) { + sb.append(" - "); + sb.append(f.getName()); + } + if (modified) { + sb.append(" *"); + } + setTitle(sb.toString()); + } + + private void handleFocusUpdate(Node n) { + CellPane p = FX.findParentOf(CellPane.class, n); + if (p != null) { + actions.setActiveCellPane(p); + pane.setActiveCellPane(p); + } + } + + public void setNotebook(Notebook b) { + actions.setNotebook(b); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/SavePane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/SavePane.java new file mode 100644 index 00000000000..2c2b742b819 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/SavePane.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.io.File; +import java.util.ArrayList; +import javafx.application.Platform; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.input.DataFormat; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.stage.DirectoryChooser; +import javafx.stage.Window; +import com.oracle.demo.rich.util.FX; + +public class SavePane extends GridPane { + private final TextField nameField; + private final ComboBox folderField; + private final ComboBox formatField; + + public SavePane() { + nameField = new TextField(); + setHgrow(nameField, Priority.ALWAYS); + setFillWidth(nameField, Boolean.TRUE); + + folderField = new ComboBox<>(); + setHgrow(folderField, Priority.ALWAYS); + setFillWidth(folderField, Boolean.TRUE); + + formatField = new ComboBox<>(); + + Button browse = new Button("Browse"); + setFillWidth(browse, Boolean.TRUE); + browse.setOnAction((ev) -> { + browse(); + }); + + int r = 0; + add(label("Save As:"), 0, r); + add(nameField, 1, r, 3, 1); + r++; + add(label("Where:"), 0, r); + add(folderField, 1, r); + add(browse, 2, r); + r++; + add(label("File Format:"), 0, r); + add(formatField, 1, r, 2, 1); + + setHgap(10); + setVgap(5); + setPadding(new Insets(10)); + + Platform.runLater(() -> { + nameField.selectAll(); + nameField.requestFocus(); + }); + } + + private static Label label(String text) { + Label t = new Label(text); + setHalignment(t, HPos.RIGHT); + return t; + } + + public void setFile(File f) { + if (f == null) { + nameField.setText("Untitled.rich"); + setDir(null); + } else { + nameField.setText(f.getName()); + setDir(f.getParentFile()); + } + } + + private void setDir(File dir) { + if (dir == null) { + dir = new File(System.getProperty("user.home")); + } + ArrayList fs = new ArrayList<>(); + File f = dir; + do { + fs.add(f); + f = f.getParentFile(); + } while (f != null); + folderField.getItems().setAll(fs); + folderField.getSelectionModel().select(dir); + } + + public void setFormat(DataFormat f) { + // TODO + } + + public File getFile() { + File dir = getDir(); + // TODO extension based on data format + return new File(dir, nameField.getText()); + } + + public DataFormat getFileFormat() { + return null; // FIX + } + + private File getDir() { + return folderField.getSelectionModel().getSelectedItem(); + } + + private void browse() { + DirectoryChooser ch = new DirectoryChooser(); + ch.setTitle("Choose Folder"); + ch.setInitialDirectory(getDir()); + Window w = FX.getParentWindow(this); + File f = ch.showDialog(w); + if (f != null) { + setDir(f); + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/ScriptEngine.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/ScriptEngine.java new file mode 100644 index 00000000000..a5415a8bb4c --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/ScriptEngine.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.scene.Node; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.ListView; +import javafx.scene.image.Image; +import javafx.scene.paint.Color; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; + +/** + * A mock script engine for the notebook. + */ +public class ScriptEngine { + public ScriptEngine() { + } + + /** + * Executes the script and returns the result. + * Result object can be one of the following: + * - a Throwable (either returned or thrown when executing the script) + * - a String for a text result + * - a Supplier that creates a Node to be inserted into the output pane + * @param src the source script + * @return the result of computation + */ + public Object executeScript(String src) throws Throwable { + // simulate processing + Thread.sleep(500); // FIX + + if (src == null) { + return null; + } else if (src.contains("text")) { + return """ + Multi-line execution result. + Line 1. + Line 2. + Line 3. + Completed. + """; + } else if (src.contains("json")) { + JsonContentWithAsyncUpdate c = new JsonContentWithAsyncUpdate(10_000_000); + return new CodeTextModel(c) { + { + c.setUpdater((ix) -> { + TextPos p = new TextPos(ix, 0); + int len = getPlainText(ix).length(); + fireChangeEvent(p, p, len, 0, 0); + }); + } + }; + } else if (src.contains("node")) { + return new Supplier() { + @Override + public Node get() { + return new ListView(FXCollections.observableArrayList( + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "nineteen", + "twenty" + )); + } + }; + } else if (src.contains("image")) { + return executeInFx(this::generateImage); + } else { + throw new Error("script failed"); + } + } + + private Image generateImage() { + int w = 700; + int h = 500; + Canvas c = new Canvas(w, h); + GraphicsContext g = c.getGraphicsContext2D(); + g.setFill(Color.gray(1.0)); + g.fillRect(0, 0, w, h); + + g.setLineWidth(0.25); + + Random rnd = new Random(); + for(int i=0; i<128; i++) { + double x = rnd.nextInt(w); + double y = rnd.nextInt(h); + double r = rnd.nextInt(64); + int hue = rnd.nextInt(360); + + g.setFill(Color.hsb(hue, 0.5, 1.0, 0.5)); + g.fillOval(x - r, y - r, r + r, r + r); + + g.setStroke(Color.hsb(hue, 0.5, 0.5, 1.0)); + g.strokeOval(x - r, y - r, r + r, r + r); + } + return c.snapshot(null, null); + } + + private static Object executeInFx(Supplier gen) { + AtomicReference result = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + Platform.runLater(() -> { + try { + Object r = gen.get(); + result.set(r); + } catch (Throwable e) { + result.set(e); + } finally { + latch.countDown(); + } + }); + + try { + latch.await(); + return result.get(); + } catch (InterruptedException e) { + return e; + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/SimpleJsonDecorator.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/SimpleJsonDecorator.java new file mode 100644 index 00000000000..c546ecfcdf9 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/SimpleJsonDecorator.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.util.ArrayList; +import java.util.List; +import javafx.scene.paint.Color; +import com.oracle.demo.rich.codearea.JavaSyntaxAnalyzer.Line; +import com.oracle.demo.rich.codearea.JavaSyntaxAnalyzer.Type; +import jfx.incubator.scene.control.rich.SyntaxDecorator; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.CodeTextModel; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * Super simple (and therefore not always correct) syntax decorator for JSON + * which works one line at a time. + */ +public class SimpleJsonDecorator implements SyntaxDecorator { + private static final StyleAttrs NORMAL = mkStyle(Color.BLACK); + private static final StyleAttrs NUMBER = mkStyle(Color.MAGENTA); + private static final StyleAttrs STRING = mkStyle(Color.BLUE); + + public SimpleJsonDecorator() { + } + + @Override + public void handleChange(CodeTextModel m, TextPos start, TextPos end, int top, int added, int bottom) { + } + + @Override + public RichParagraph createRichParagraph(CodeTextModel model, int index) { + String text = model.getPlainText(index); + List segments = new Analyzer(text).parse(); + RichParagraph.Builder b = RichParagraph.builder(); + for (Seg seg : segments) { + b.addSegment(seg.text, seg.style); + } + return b.build(); + } + + private static StyleAttrs mkStyle(Color c) { + return StyleAttrs.builder().setTextColor(c).build(); + } + + private static record Seg(StyleAttrs style, String text) { + } + + private enum State { + NUMBER, + STRING, + TEXT, + VALUE, + } + + private static class Analyzer { + private final String text; + private final ArrayList segments = new ArrayList<>(); + private static final int EOF = -1; + private int start; + private int pos; + private State state = State.TEXT; + + public Analyzer(String text) { + this.text = text; + } + + private int peek(int delta) { + int ix = pos + delta; + if ((ix >= 0) && (ix < text.length())) { + return text.charAt(ix); + } + return EOF; + } + + private void addSegment() { + StyleAttrs type = toStyleAttrs(state); + addSegment(type); + } + + private StyleAttrs toStyleAttrs(State s) { + switch (s) { + case STRING: + return STRING; + case NUMBER: + return NUMBER; + case VALUE: + default: + return NORMAL; + } + } + + private void addSegment(StyleAttrs style) { + if (pos > start) { + String s = text.substring(start, pos); + segments.add(new Seg(style, s)); + start = pos; + } + } + + private Error err(String text) { + return new Error(text + " state=" + state + " pos=" + pos); + } + + private int parseNumber() { + int ix = indexOfNonNumber(); + if (ix < 0) { + return 0; + } + String s = text.substring(pos, pos + ix); + try { + Double.parseDouble(s); + return ix; + } catch (NumberFormatException e) { + } + return 0; + } + + private int indexOfNonNumber() { + int i = 0; + for (;;) { + int c = peek(i); + switch (c) { + case EOF: + return i; + // we'll parse integers only for now case '.': + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + i++; + continue; + default: + return i; + } + } + } + + public List parse() { + start = 0; + for (;;) { + int c = peek(0); + switch (c) { + case EOF: + addSegment(); + return segments; + case '"': + switch (state) { + case TEXT: + case VALUE: + addSegment(); + state = State.STRING; + pos++; + break; + case STRING: + pos++; + addSegment(); + state = State.TEXT; + break; + default: + throw err("state must be either TEXT, STRING, or VALUE"); + } + break; + case '=': + state = State.VALUE; + break; + //case '.': + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + switch (state) { + case VALUE: + int len = parseNumber(); + if (len > 0) { + addSegment(); + state = State.NUMBER; + pos += len; + addSegment(); + } + break; + } + break; + default: + break; + } + + pos++; + } + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/TextCellTextModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/TextCellTextModel.java new file mode 100644 index 00000000000..fc8f70601f4 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/TextCellTextModel.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook; + +import java.io.IOException; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.ContentChange; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; +import jfx.incubator.scene.control.rich.model.StyledOutput; + +public class TextCellTextModel extends EditableRichTextModel { + private boolean modified; + + public TextCellTextModel() { + addChangeListener(new Listener() { + @Override + public void onContentChange(ContentChange ch) { + setModified(true); + } + }); + } + + public boolean isModified() { + return modified; + } + + public void setModified(boolean on) { + modified = on; + } + + public void setText(String text) { + replace(null, TextPos.ZERO, TextPos.ZERO, text, false); + setModified(false); + } + + public String getPlainText() { + try { + StyledOutput out = StyledOutput.forPlainText(); + TextPos end = getDocumentEnd(); + export(TextPos.ZERO, end, out); + return out.toString(); + } catch (IOException e) { + return null; + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/data/CellInfo.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/data/CellInfo.java new file mode 100644 index 00000000000..1c6cb9e1867 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/data/CellInfo.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook.data; + +import com.oracle.demo.rich.notebook.CellType; +import com.oracle.demo.rich.notebook.CodeCellTextModel; +import com.oracle.demo.rich.notebook.TextCellTextModel; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * This data structure represents a cell in the notebook. + */ +public class CellInfo { + private CellType type; + private String source; + private CodeCellTextModel codeModel; + private TextCellTextModel textModel; + + public CellInfo(CellType t) { + this.type = t; + } + + public final CellType getCellType() { + return type; + } + + public final void setCellType(CellType t) { + type = t; + } + + public boolean isCode() { + return getCellType() == CellType.CODE; + } + + public boolean isText() { + return getCellType() == CellType.TEXT; + } + + public final StyledTextModel getModel() { + switch (type) { + case CODE: + if (textModel != null) { + if (textModel.isModified()) { + source = textModel.getPlainText(); + codeModel = null; + } + } + if (codeModel == null) { + codeModel = new CodeCellTextModel(); + codeModel.setText(source); + } + return codeModel; + case TEXT: + default: + if (codeModel != null) { + if (codeModel.isModified()) { + source = codeModel.getText(); + textModel = null; + } + } + if (textModel == null) { + textModel = new TextCellTextModel(); + textModel.setText(source); + } + return textModel; + } + } + + private void handleTypeChange(CellType old, CellType type) { + switch (type) { + case CODE: + // TODO + case TEXT: + default: + break; + } + } + + public String getSource() { + switch (type) { + case CODE: + if (codeModel != null) { + if (codeModel.isModified()) { + source = codeModel.getText(); + codeModel.setModified(false); + } + } + break; + case TEXT: + default: + if (textModel != null) { + if (textModel.isModified()) { + source = textModel.getPlainText(); + textModel.setModified(false); + } + } + break; + } + return source; + } + + public void setSource(String text) { + this.source = text; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/data/Notebook.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/data/Notebook.java new file mode 100644 index 00000000000..7af9c6174fa --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/data/Notebook.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.notebook.data; + +import java.util.ArrayList; + +/** + * Notebook Data Object. + */ +public class Notebook { + private final ArrayList cells = new ArrayList<>(); + + public Notebook() { + } + + public int size() { + return cells.size(); + } + + public CellInfo getCell(int ix) { + return cells.get(ix); + } + + public void add(CellInfo cell) { + cells.add(cell); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/notebook.css b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/notebook.css new file mode 100644 index 00000000000..2520f218d24 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/notebook/notebook.css @@ -0,0 +1,31 @@ +.active-cell .code-bar { + -fx-background-color: #488f48; + -fx-background-radius: 2; +} + +.exec-label { + -fx-text-fill: gray; +} + +.cell-pane:focus-within .exec-label { + -fx-text-fill: black; +} + +.exec-label:executing { + -fx-text-fill: red; + -fx-font-weight: bold; +} + +.output-text .content { + -fx-background-color:f8f8f8; + -fx-background-insets:0; +} + +.output-error .content { + -fx-background-color:fff0f0; + -fx-background-insets:0; +} + +.image-result { + -fx-background-color: #888888; +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/BifurcationDiagram.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/BifurcationDiagram.java new file mode 100644 index 00000000000..b3d71727538 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/BifurcationDiagram.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; + +public class BifurcationDiagram { + private static final double min = 2.4; + private static final double max = 4.0; + + public static Pane generate() { + Pane p = new Pane(); + p.setPrefSize(600, 200); + p.widthProperty().addListener((x) -> update(p)); + p.heightProperty().addListener((x) -> update(p)); + update(p); + return p; + } + + protected static void update(Pane p) { + double w = p.getWidth(); + double h = p.getHeight(); + + if ((w < 1) || (h < 1)) { + return; + } else if (w > 600) { + w = 600; + } + + Canvas c = new Canvas(w, h); + GraphicsContext g = c.getGraphicsContext2D(); + + g.setFill(Color.gray(0.9)); + g.fillRect(0, 0, w, h); + + int count = 1000; + int start = 500; + double r = 0.3; + g.setFill(Color.rgb(0, 0, 0, 0.2)); + + for (double λ = min; λ < max; λ += 0.001) { + double x = 0.5; + for (int i = 0; i < count; i++) { + x = λ * x * (1.0 - x); + if (i > start) { + double px = w * (λ - min) / (max - min); + double py = h * (1.0 - x); + + g.fillOval(px - r, py - r, r + r, r + r); + } + } + } + + p.getChildren().setAll(c); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/CssToolPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/CssToolPane.java new file mode 100644 index 00000000000..644dc809b29 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/CssToolPane.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import java.nio.charset.Charset; +import java.util.Base64; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.paint.Color; +import javafx.stage.Window; +import com.oracle.demo.rich.util.FX; + +/** + * CSS Tool + */ +public class CssToolPane extends BorderPane { + private final TextArea cssField; + private static String oldStylesheet; + + public CssToolPane() { + cssField = new TextArea(); + cssField.setId("CssPlaygroundPaneCss"); + cssField.setMaxWidth(Double.POSITIVE_INFINITY); + cssField.setMaxHeight(Double.POSITIVE_INFINITY); + + Button updateButton = FX.button("Update", this::update); + + // why can't I fill the width of the container with this grid pane?? + GridPane p = new GridPane(); + p.setPadding(new Insets(10)); + p.setHgap(5); + p.setVgap(5); + int r = 0; + p.add(new Label("Custom CSS:"), 0, r); + r++; + p.add(cssField, 0, r, 3, 1); + r++; + p.add(updateButton, 2, r); + GridPane.setHgrow(cssField, Priority.ALWAYS); + GridPane.setVgrow(cssField, Priority.ALWAYS); + + setCenter(p); + } + + private void update() { + String css = cssField.getText(); + applyStyleSheet(css); + } + + private static String toCssColor(Color c) { + int r = toInt8(c.getRed()); + int g = toInt8(c.getGreen()); + int b = toInt8(c.getBlue()); + return String.format("#%02X%02X%02X", r, g, b); + } + + private static int toInt8(double x) { + int v = (int)Math.round(x * 255); + if (v < 0) { + return 0; + } else if (v > 255) { + return 255; + } + return v; + } + + private static String encode(String s) { + if (s == null) { + return null; + } + Charset utf8 = Charset.forName("utf-8"); + byte[] b = s.getBytes(utf8); + return "data:text/css;base64," + Base64.getEncoder().encodeToString(b); + } + + private static void applyStyleSheet(String styleSheet) { + String ss = encode(styleSheet); + if (ss != null) { + for (Window w : Window.getWindows()) { + Scene scene = w.getScene(); + if (scene != null) { + ObservableList sheets = scene.getStylesheets(); + if (oldStylesheet != null) { + sheets.remove(oldStylesheet); + } + sheets.add(ss); + } + } + } + oldStylesheet = ss; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DataFrame.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DataFrame.java new file mode 100644 index 00000000000..ebbea674b34 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DataFrame.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import java.util.ArrayList; + +public class DataFrame { + private String[] columns; + private final ArrayList rows = new ArrayList(); + + public DataFrame() { + } + + public static DataFrame parse(String[] lines) { + DataFrame f = new DataFrame(); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + String[] ss = line.split("\\|"); + if (i == 0) { + f.setColumns(ss); + } else { + f.addValues(ss); + } + } + return f; + } + + public String[] getColumnNames() { + return columns; + } + + public void setColumns(String[] columns) { + this.columns = columns; + } + + public void addValues(String[] ss) { + rows.add(ss); + } + + public int getRowCount() { + return rows.size(); + } + + public String[] getRow(int ix) { + return rows.get(ix); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoColorSideDecorator.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoColorSideDecorator.java new file mode 100644 index 00000000000..85f8b4dbde8 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoColorSideDecorator.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import javafx.scene.Node; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import jfx.incubator.scene.control.rich.SideDecorator; + +public class DemoColorSideDecorator implements SideDecorator { + public DemoColorSideDecorator() { + } + + @Override + public double getPrefWidth(double viewWidth) { + return 20.0; + } + + @Override + public Node getNode(int modelIndex, boolean forMeasurement) { + int num = 36; + double a = 360.0 * (modelIndex % num) / num; + Color c = Color.hsb(a, 0.5, 1.0); + + Region r = new Region(); + r.setOpacity(1.0); + r.setBackground(new Background(new BackgroundFill(c, null, null))); + return r; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoModel.java new file mode 100644 index 00000000000..0d89d03fd19 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoModel.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; +import java.util.Arrays; +import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleStringProperty; +import jfx.incubator.scene.control.rich.model.RichTextFormatHandler; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; + +/** + * RichTextArea demo model. + */ +public class DemoModel extends SimpleViewOnlyStyledModel { + private final SimpleStringProperty textField = new SimpleStringProperty(); + + public DemoModel() { + // see RichTextAreaDemo.css + String ARABIC = "arabic"; + String CODE = "code"; + String RED = "red"; + String GREEN = "green"; + String GRAY = "gray"; + String LARGE = "large"; + String BOLD = "bold"; + String ITALIC = "italic"; + String STRIKETHROUGH = "strikethrough"; + String UNDERLINE = "underline"; + + addSegment("RichTextArea Control", "-fx-font-size:200%;", UNDERLINE); + nl(2); + +// addParagraph(() -> { +// Region r = new Region(); +// r.getchi +// r.setw 300, 50); +// r.setFill(Color.RED); +// return r; +// }); + + addSegment("/**", null, RED, CODE); + nl(); + addSegment(" * Syntax Highlight Demo.", null, RED, CODE); + nl(); + addSegment(" */", null, RED, CODE); + nl(); + addSegment("public class ", null, GREEN, CODE); + addSegment("SyntaxHighlightDemo ", null, CODE); + addSegment("extends ", null, GREEN, CODE); + addSegment("Application {", null, CODE); + nl(); + addSegment("\tpublic static void", null, GREEN, CODE); + addSegment(" main(String[] args) {", null, CODE); + nl(); + addSegment("\t\tApplication.launch(SyntaxHighlightDemo.", null, CODE); + addSegment("class", null, CODE, GREEN); + addSegment(", args);", null, CODE); + nl(); + addSegment("\t}", null, CODE); + nl(); + addSegment("}", null, CODE); + nl(2); + // font attributes + addSegment("BOLD ", null, BOLD); + addSegment("ITALIC ", null, ITALIC); + addSegment("STRIKETHROUGH ", null, STRIKETHROUGH); + addSegment("UNDERLINE ", null, UNDERLINE); + addSegment("ALL OF THEM ", null, BOLD, ITALIC, STRIKETHROUGH, UNDERLINE); + nl(2); + // inline nodes + addSegment("Inline Nodes: "); + addNodeSegment(() -> { + TextField f = new TextField(); + f.setPrefColumnCount(20); + f.textProperty().bindBidirectional(textField); + return f; + }); + addSegment(" "); + addNodeSegment(() -> new Button("OK")); + addSegment(" "); // FIX cannot navigate over this segment + nl(2); + addSegment("A regular Arabic verb, كَتَبَ‎ kataba (to write).", null, ARABIC).nl(); + addSegment("Emojis: [🔥🦋😀😃😄😁😆😅🤣😂🙂🙃😉😊😇]", null, LARGE).nl(); + nl(); + addSegment("Halfwidth and FullWidth Forms", null, UNDERLINE).nl(); + addSegment("ABCDEFGHIJKLMNO", "-fx-font-family:monospaced;").nl(); + addSegment("ABCDEFGHIJKLMNO", "-fx-font-family:monospaced;").nl(); + addSegment(" leading and trailing whitespace ", null, CODE).nl(); + nl(3); + addSegment("Behold various types of highlights, including overlapping highlights.", null, LARGE); + highlight(7, 7, Color.rgb(255, 255, 128, 0.7)); + squiggly(36, 100, Color.RED); + highlight(46, 11, Color.rgb(255, 255, 128, 0.7)); + highlight(50, 20, Color.rgb(0, 0, 128, 0.1)); + nl(2); + addSegment("Behold various types of highlights, including overlapping highlights."); + highlight(7, 7, Color.rgb(255, 255, 128, 0.7)); + squiggly(36, 100, Color.RED); + highlight(46, 11, Color.rgb(255, 255, 128, 0.7)); + highlight(50, 20, Color.rgb(0, 0, 128, 0.1)); + nl(2); + + // FIX adding a control messes up the view with text wrap off +// addParagraph(() -> { +// TextField t = new TextField("yo"); +// t.setMaxWidth(100); +// return t; +// }); +// nl(2); + + addParagraph(this::createRect); + nl(2); + + ParagraphAttributesDemoModel.insert(this); + + addImage(DemoModel.class.getResourceAsStream("animated.gif")); + addSegment(" Fig. 1 Embedded animated GIF image.", null, GRAY, ITALIC); + nl(2); + + /* + Random r = new Random(); + for(int line=0; line<100; line++) { + int ct = r.nextInt(10); + for (int word = 0; word < ct; word++) { + int len = 1 + r.nextInt(7); + char c = '*'; + + if (word == 0) { + addSegment("L" + (size() + 1), null, GRAY); + } + + addSegment(" "); + + if (r.nextFloat() < 0.1) { + addSegment(word + "." + word(c, len), null, RED); + } else { + addSegment(word + "." + word(c, len)); + } + } + nl(); + } + */ + + nl(); + addSegment("\t\t終 The End.", "-fx-font-size:200%;"); + nl(); + + registerDataFormatHandler(new RichTextFormatHandler(), true, false, 2000); + } + + private Region createRect() { + Label t = new Label() { + @Override + protected double computePrefHeight(double w) { + return 400; + } + }; + t.setPrefSize(400, 200); + t.setMaxWidth(400); + t.textProperty().bind(Bindings.createObjectBinding( + () -> { + return String.format("%.1f x %.1f", t.getWidth(), t.getHeight()); + }, + t.widthProperty(), + t.heightProperty() + )); + t.setBackground(Background.fill(Color.LIGHTGRAY)); + + BorderPane p = new BorderPane(); + p.setLeft(t); + return p; + } + + private String word(char c, int len) { + char[] cs = new char[len]; + Arrays.fill(cs, c); + return new String(cs); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoStyledTextModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoStyledTextModel.java new file mode 100644 index 00000000000..9ae026d3f85 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/DemoStyledTextModel.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import java.text.DecimalFormat; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledTextModelViewOnlyBase; +import javafx.scene.Node; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +/** + * Demo StyledTextModel. + * Does not support editing events - populate the model first, then pass it to the control. + */ +public class DemoStyledTextModel extends StyledTextModelViewOnlyBase { + private final int size; + private final boolean monospaced; + private static final DecimalFormat format = new DecimalFormat("#,##0"); + + public DemoStyledTextModel(int size, boolean monospaced) { + this.size = size; + this.monospaced = monospaced; + } + + @Override + public int size() { + return size; + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver resolver, TextPos pos) { + return StyleAttrs.EMPTY; + } + + @Override + public String getPlainText(int index) { + RichParagraph p = getParagraph(index); + return p.getPlainText(); + } + + private static String getText(TextFlow f) { + StringBuilder sb = new StringBuilder(); + for (Node n : f.getChildrenUnmodifiable()) { + if (n instanceof Text t) { + sb.append(t.getText()); + } + } + return sb.toString(); + } + + @Override + public RichParagraph getParagraph(int ix) { + RichParagraph.Builder b = RichParagraph.builder(); + String s = format.format(ix + 1); + String sz = format.format(size); + String[] css = monospaced ? new String[] { "monospaced" } : null; + + b.addSegment(s, "-fx-fill:darkgreen;", css); + b.addSegment(" / ", null, css); + b.addSegment(sz, "-fx-fill:black;", css); + if (monospaced) { + b.addSegment(" (monospaced)", null, css); + } + + if ((ix % 10) == 9) { + String words = generateWords(ix); + b.addSegment(words, null, css); + } + return b.build(); + } + + private String generateWords(int ix) { + String s = String.valueOf(ix); + StringBuilder sb = new StringBuilder(128); + for (char c: s.toCharArray()) { + String digit = getDigit(c); + sb.append(digit); + } + return sb.toString(); + } + + private String getDigit(char c) { + switch (c) { + case '0': + return " zero"; + case '1': + return " one"; + case '2': + return " two"; + case '3': + return " three"; + case '4': + return " four"; + case '5': + return " five"; + case '6': + return " six"; + case '7': + return " seven"; + case '8': + return " eight"; + default: + return " nine"; + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ExamplesModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ExamplesModel.java new file mode 100644 index 00000000000..bf9e625479b --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ExamplesModel.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import javafx.beans.property.SimpleStringProperty; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledTextModelViewOnlyBase; +import javafx.scene.control.TextField; +import javafx.scene.paint.Color; + +/** This model contains code examples used in the documentation. */ +public class ExamplesModel extends StyledTextModelViewOnlyBase { + /** properties in the model allow for inline controls */ + private final SimpleStringProperty exampleProperty = new SimpleStringProperty(); + + public ExamplesModel() { + } + + @Override + public int size() { + return 10; + } + + @Override + public String getPlainText(int index) { + return getParagraph(index).getPlainText(); + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver resolver, TextPos pos) { + return null; + } + + @Override + public RichParagraph getParagraph(int index) { + switch(index) { + case 0: + { + StyleAttrs a1 = StyleAttrs.builder().setBold(true).build(); + RichParagraph.Builder b = RichParagraph.builder(); + b.addSegment("Example: ", a1); + b.addSegment("spelling, highlights"); + b.addSquiggly(9, 8, Color.RED); + b.addHighlight(19, 4, Color.rgb(255, 128, 128, 0.5)); + b.addHighlight(20, 7, Color.rgb(128, 255, 128, 0.5)); + return b.build(); + } + case 4: + { + RichParagraph.Builder b = RichParagraph.builder(); + b.addSegment("Input field: "); + // creates an embedded control bound to a property within this model + b.addInlineNode(() -> { + TextField t = new TextField(); + t.textProperty().bindBidirectional(exampleProperty); + return t; + }); + return b.build(); + } + } + return RichParagraph.builder().build(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/FontOption.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/FontOption.java new file mode 100644 index 00000000000..8914a1f655f --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/FontOption.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Insets; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.text.Font; +import com.oracle.demo.rich.util.FX; + +/** + * Font Option Bound to a Property. + */ +public class FontOption extends HBox { + private final SimpleObjectProperty property = new SimpleObjectProperty<>(); + private final ComboBox fontField = new ComboBox<>(); + private final ComboBox styleField = new ComboBox<>(); + private final ComboBox sizeField = new ComboBox<>(); + + public FontOption(String name, boolean allowNull, ObjectProperty p) { + FX.name(this, name); + if (p != null) { + property.bindBidirectional(p); + } + + FX.name(fontField, name + "_FONT"); + fontField.getItems().setAll(collectFonts(allowNull)); + fontField.getSelectionModel().selectedItemProperty().addListener((x) -> { + String fam = fontField.getSelectionModel().getSelectedItem(); + updateStyles(fam); + update(); + }); + + FX.name(styleField, name + "_STYLE"); + styleField.getSelectionModel().selectedItemProperty().addListener((x) -> { + update(); + }); + + FX.name(sizeField, name + "_SIZE"); + sizeField.getItems().setAll( + 1.0, + 2.5, + 6.0, + 8.0, + 10.0, + 11.0, + 12.0, + 16.0, + 24.0, + 32.0, + 48.0, + 72.0, + 144.0, + 480.0 + ); + sizeField.getSelectionModel().selectedItemProperty().addListener((x) -> { + update(); + }); + + getChildren().setAll(fontField, styleField, sizeField); + setHgrow(fontField, Priority.ALWAYS); + setMargin(sizeField, new Insets(0, 0, 0, 2)); + + setFont(property.get()); + } + + public SimpleObjectProperty getProperty() { + return property; + } + + public void select(String name) { + fontField.getSelectionModel().select(name); + } + + public Font getFont() { + String name = fontField.getSelectionModel().getSelectedItem(); + if (name == null) { + return null; + } + String style = styleField.getSelectionModel().getSelectedItem(); + if (!isBlank(style)) { + name = name + " " + style; + } + Double size = sizeField.getSelectionModel().getSelectedItem(); + if (size == null) { + size = 12.0; + } + return new Font(name, size); + } + + private static boolean isBlank(String s) { + return s == null ? true : s.trim().length() == 0; + } + + protected void updateStyles(String family) { + String st = styleField.getSelectionModel().getSelectedItem(); + if (st == null) { + st = ""; + } + + List ss = Font.getFontNames(family); + for (int i = 0; i < ss.size(); i++) { + String s = ss.get(i); + if (s.startsWith(family)) { + s = s.substring(family.length()).trim(); + ss.set(i, s); + } + } + Collections.sort(ss); + + styleField.getItems().setAll(ss); + int ix = ss.indexOf(st); + if (ix >= 0) { + styleField.getSelectionModel().select(ix); + } + } + + protected void update() { + Font f = getFont(); + property.set(f); + } + + private void setFont(Font f) { + String name; + String style; + double size; + if (f == null) { + name = null; + style = null; + size = 12.0; + } else { + name = f.getFamily(); + style = f.getStyle(); + size = f.getSize(); + } + fontField.getSelectionModel().select(name); + styleField.getSelectionModel().select(style); + sizeField.getSelectionModel().select(size); + } + + protected List collectFonts(boolean allowNull) { + ArrayList rv = new ArrayList<>(); + if (allowNull) { + rv.add(null); + } + rv.addAll(Font.getFamilies()); + return rv; + } + + public void selectSystemFont() { + FX.select(fontField, "System"); + FX.select(styleField, ""); + FX.select(sizeField, 12.0); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/InlineNodesModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/InlineNodesModel.java new file mode 100644 index 00000000000..53783cc72a1 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/InlineNodesModel.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; +import javafx.beans.property.SimpleStringProperty; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; +import javafx.scene.control.Button; +import javafx.scene.control.TextField; + +/** + * A demo model with inline Nodes. + */ +public class InlineNodesModel extends SimpleViewOnlyStyledModel { + private final SimpleStringProperty textField = new SimpleStringProperty(); + + public InlineNodesModel() { + String ARABIC = "arabic"; + String CODE = "code"; + String RED = "red"; + String GREEN = "green"; + String UNDER = "underline"; + String GRAY = "gray"; + String LARGE = "large"; + String ITALIC = "italic"; + + addSegment("Inline Nodes", null, UNDER, LARGE); + nl(); + // trailing text + addNodeSegment(() -> { + TextField f = new TextField(); + f.setPrefColumnCount(20); + f.textProperty().bindBidirectional(textField); + return f; + }); + addSegment(" ", null, LARGE); + addNodeSegment(() -> new Button("OK")); + addSegment(" trailing segment.", null, LARGE); // FIX cannot navigate over this segment + nl(); + + // leading text + addSegment("Leading text", null, LARGE); + addNodeSegment(() -> { + TextField f = new TextField(); + f.setPrefColumnCount(20); + f.textProperty().bindBidirectional(textField); + return f; + }); + addSegment("- in between text-", null, LARGE); + addNodeSegment(() -> new Button("Find")); + nl(); + + // leading and trailing text + addSegment("Leading text", null, LARGE); + addNodeSegment(() -> { + TextField f = new TextField(); + f.setPrefColumnCount(20); + f.textProperty().bindBidirectional(textField); + return f; + }); + addSegment("- in between text-", null, LARGE); + addNodeSegment(() -> new Button("Find")); + addSegment(" trailing segment.", null, LARGE); + nl(); + + // adjacent nodes + addNodeSegment(() -> new Button("One")); + addNodeSegment(() -> new Button("Two")); + addNodeSegment(() -> new Button("Three")); + addNodeSegment(() -> new Button("Four")); + addNodeSegment(() -> new Button("Five")); + nl(); + addSegment("", null, LARGE); + nl(); + + addSegment("A regular text segment for reference.", null, LARGE); + nl(); + addSegment("The End █", null, LARGE); + nl(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/LargeTextModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/LargeTextModel.java new file mode 100644 index 00000000000..5756b87d8d8 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/LargeTextModel.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import java.util.Random; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; + +public class LargeTextModel extends SimpleViewOnlyStyledModel { + private final String STYLE = "-fx-font-size:500%"; + private final Random random = new Random(); + + public LargeTextModel(int lineCount) { + for (int i = 0; i < lineCount; i++) { + addLine(i); + } + } + + private void addLine(int n) { + StringBuilder sb = new StringBuilder(); + sb.append("L").append(n).append(' '); + int ct; + if (random.nextFloat() < 0.01f) { + ct = 200; + } else { + ct = random.nextInt(10); + } + + for (int i = 0; i < ct; i++) { + sb.append(" ").append(i); + int len = random.nextInt(10) + 1; + for (int j = 0; j < len; j++) { + sb.append('*'); + } + } + addSegment(sb.toString(), STYLE); + nl(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ModelChoice.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ModelChoice.java new file mode 100644 index 00000000000..fccd6b26e07 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ModelChoice.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import java.io.IOException; +import javafx.scene.paint.Color; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; +import jfx.incubator.scene.control.rich.model.PlainTextModel; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledInput; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +public enum ModelChoice { + DEMO("Demo"), + PARAGRAPH("Paragraph Attributes"), + WRITING_SYSTEMS_EDITABLE("Writing Systems (Editable)"), + EDITABLE_STYLED("❤ Editable Rich Text Model"), + BILLION_LINES("2,000,000,000 Lines"), + NOTEBOOK_STACK("Notebook: Embedded Rich Text Areas"), + NOTEBOOK("Notebook: Embedded Chart"), + NOTEBOOK2("Notebook: SQL Queries"), + EDITABLE_PLAIN("Plaintext with Syntax Highlighting"), + NULL("null"), + EXAMPLES("Examples"), + INLINE("Inline Nodes"), + MONOSPACED("Monospaced"), + TABS("Tabs"), + UNEVEN_SMALL("Uneven Small"), + UNEVEN_LARGE("Uneven Large"), + WRITING_SYSTEMS("Writing Systems"), + ZERO_LINES("0 Lines"), + ONE_LINE("1 Line"), + TEN_LINES("10 Lines"), + THOUSAND_LINES("1,000 Lines"), + LARGE_TEXT("Large text"), + LARGE_TEXT_LONG("Large Text, Long"), + NO_LAST_NEWLINE_SHORT("No Last Newline, Short"), + NO_LAST_NEWLINE_MEDIUM("No Last Newline, Medium"), + NO_LAST_NEWLINE_LONG("No Last Newline, Long"), + ; + + private final String name; + + ModelChoice(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + + public static StyledTextModel create(ModelChoice ch) { + if(ch == null) { + return null; + } + + switch(ch) { + case BILLION_LINES: + return new DemoStyledTextModel(2_000_000_000, false); + case DEMO: + return new DemoModel(); + case EXAMPLES: + return new ExamplesModel(); + case INLINE: + return new InlineNodesModel(); + case EDITABLE_PLAIN: + { + PlainTextModel m = new PlainTextModel() { + private static final String DIGITS = "-fx-fill:magenta;"; + + @Override + public RichParagraph getParagraph(int index) { + String text = getPlainText(index); + RichParagraph.Builder b = RichParagraph.builder(); + int start = 0; + int sz = text.length(); + boolean num = false; + for (int i = 0; i < sz; i++) { + char c = text.charAt(i); + if (num != Character.isDigit(c)) { + if (i > start) { + String s = text.substring(start, i); + String style = num ? DIGITS : null; + b.addSegment(s, style, null); + start = i; + } + num = !num; + } + } + if (start < sz) { + String s = text.substring(start); + String style = num ? DIGITS : null; + b.addSegment(s, style, null); + } + return b.build(); + } + }; + return m; + } + case EDITABLE_STYLED: + return new EditableRichTextModel(); + case LARGE_TEXT: + return new LargeTextModel(10); + case LARGE_TEXT_LONG: + return new LargeTextModel(5_000); + case NO_LAST_NEWLINE_SHORT: + return new NoLastNewlineModel(1); + case NO_LAST_NEWLINE_MEDIUM: + return new NoLastNewlineModel(5); + case NO_LAST_NEWLINE_LONG: + return new NoLastNewlineModel(300); + case MONOSPACED: + return new DemoStyledTextModel(2_000_000_000, true); + case NOTEBOOK: + return new NotebookModel(); + case NOTEBOOK2: + return new NotebookModel2(); + case NOTEBOOK_STACK: + return new NotebookModelStacked(); + case NULL: + return null; + case ONE_LINE: + return new DemoStyledTextModel(1, false); + case PARAGRAPH: + return new ParagraphAttributesDemoModel(); + case TABS: + return tabs(); + case TEN_LINES: + return new DemoStyledTextModel(10, false); + case THOUSAND_LINES: + return new DemoStyledTextModel(1_000, false); + case UNEVEN_SMALL: + return new UnevenStyledTextModel(20); + case UNEVEN_LARGE: + return new UnevenStyledTextModel(2000); + case WRITING_SYSTEMS: + return writingSystemsPlain(); + case WRITING_SYSTEMS_EDITABLE: + return writingSystems(); + case ZERO_LINES: + return new DemoStyledTextModel(0, false); + default: + throw new Error("?" + ch); + } + } + + private static StyledTextModel writingSystemsPlain() { + try { + return SimpleViewOnlyStyledModel.from(WritingSystemsDemo.getText()); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + private static StyledTextModel tabs() { + try { + return SimpleViewOnlyStyledModel.from("0123456789012345678901234567890\n0\n\t1\n\t\t2\n\t\t\t3\n\t\t\t\t4\n0\n"); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + private static StyledTextModel writingSystems() { + StyleAttrs name = StyleAttrs.builder(). + setFontSize(24). + setTextColor(Color.gray(0.5)). + build(); + + StyleAttrs value = StyleAttrs.builder(). + setFontSize(24). + build(); + + EditableRichTextModel m = new EditableRichTextModel(); + String[] ss = WritingSystemsDemo.PAIRS; + for (int i = 0; i < ss.length;) { + String s = ss[i++] + ": "; + append(m, s, name); + + s = ss[i++]; + append(m, s, value); + + append(m, "\n", null); + } + return m; + } + + // TODO add to StyledModel? + private static void append(StyledTextModel m, String text, StyleAttrs style) { + TextPos p = m.getDocumentEnd(); + m.replace(null, p, p, StyledInput.of(text, style), false); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/MultipleStackedBoxWindow.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/MultipleStackedBoxWindow.java new file mode 100644 index 00000000000..2c18a8f1beb --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/MultipleStackedBoxWindow.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import javafx.geometry.Insets; +import jfx.incubator.scene.control.rich.LineNumberDecorator; +import jfx.incubator.scene.control.rich.RichTextArea; +import javafx.scene.Scene; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Menu; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextArea; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import com.oracle.demo.rich.util.FX; + +/** + * Test Window that stacks multiple RichTextAreas and other components either vertically or horizontally. + */ +public class MultipleStackedBoxWindow extends Stage { + + public MultipleStackedBoxWindow(boolean vertical) { + RichTextArea a1 = new RichTextArea(NotebookModelStacked.m1()); + a1.setWrapText(true); + a1.setLeftDecorator(new LineNumberDecorator()); + createPopupMenu(a1); + + TextArea t1 = new TextArea("This TextArea has wrap text property set to false."); + + TextArea t2 = new TextArea("This TextArea has wrap text property set to true."); + t2.setWrapText(true); + + RichTextArea a2 = new RichTextArea(NotebookModelStacked.m2()); + a2.setWrapText(true); + a2.setLeftDecorator(new LineNumberDecorator()); + createPopupMenu(a2); + + PrefSizeTester tester = new PrefSizeTester(); + + ScrollPane sp = new ScrollPane(); + + if (vertical) { + a1.setUseContentHeight(true); + a2.setUseContentHeight(true); + + VBox vb = new VBox( + a1, + t1, + a2, + t2, + tester + ); + sp.setContent(vb); + sp.setFitToWidth(true); + + setTitle("Test Vertical Stack"); + setWidth(600); + setHeight(1200); + FX.name(this, "VerticalStack"); + } else { + a1.setUseContentWidth(true); + a2.setUseContentWidth(true); + + HBox hb = new HBox( + a1, + t1, + a2, + t2, + tester + ); + sp.setContent(hb); + sp.setFitToHeight(true); + + setTitle("Test Horizontal Stack"); + setWidth(1200); + setHeight(600); + FX.name(this, "HorizontalStack"); + } + + Scene scene = new Scene(sp); + scene.getStylesheets().addAll( + RichTextAreaWindow.class.getResource("RichTextAreaDemo.css").toExternalForm() + ); + setScene(scene); + } + + protected void createPopupMenu(RichTextArea t) { + FX.setPopupMenu(t, () -> { + Menu m; + ContextMenu c = new ContextMenu(); + // left side + m = FX.menu(c, "Left Side"); + FX.checkItem(m, "null", t.getLeftDecorator() == null, (on) -> { + if (on) { + t.setLeftDecorator(null); + } + }); + FX.checkItem(m, "Line Numbers", t.getLeftDecorator() instanceof LineNumberDecorator, (on) -> { + if (on) { + t.setLeftDecorator(new LineNumberDecorator()); + } + }); + FX.checkItem(m, "Colors", t.getLeftDecorator() instanceof DemoColorSideDecorator, (on) -> { + if (on) { + t.setLeftDecorator(new DemoColorSideDecorator()); + } + }); + // right side + m = FX.menu(c, "Right Side"); + FX.checkItem(m, "null", t.getRightDecorator() == null, (on) -> { + if (on) { + t.setRightDecorator(null); + } + }); + FX.checkItem(m, "Line Numbers", t.getRightDecorator() instanceof LineNumberDecorator, (on) -> { + if (on) { + t.setRightDecorator(new LineNumberDecorator()); + } + }); + FX.checkItem(m, "Colors", t.getRightDecorator() instanceof DemoColorSideDecorator, (on) -> { + if (on) { + t.setRightDecorator(new DemoColorSideDecorator()); + } + }); + // content padding + m = FX.menu(c, "Content Padding"); + FX.checkItem(m, "null", t.getContentPadding() == null, (on) -> { + if (on) { + t.setContentPadding(null); + } + }); + FX.checkItem(m, "1", new Insets(1).equals(t.getContentPadding()), (on) -> { + if (on) { + t.setContentPadding(new Insets(1)); + } + }); + FX.checkItem(m, "10", new Insets(10).equals(t.getContentPadding()), (on) -> { + if (on) { + t.setContentPadding(new Insets(10)); + } + }); + FX.checkItem(m, "55.75", new Insets(55.75).equals(t.getContentPadding()), (on) -> { + if (on) { + t.setContentPadding(new Insets(55.75)); + } + }); + + FX.checkItem(c, "Wrap Text", t.isWrapText(), (on) -> t.setWrapText(on)); + return c; + }); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NoLastNewlineModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NoLastNewlineModel.java new file mode 100644 index 00000000000..adff2084b76 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NoLastNewlineModel.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; + +public class NoLastNewlineModel extends SimpleViewOnlyStyledModel { + public NoLastNewlineModel(int lineCount) { + for(int i=0; i OUTLINE = new StyleAttribute<>("OUTLINE", Boolean.class, true); + + public NotebookModel() { + String GREEN = "green"; + String GRAY = "gray"; + String EQ = "equation"; + String SUB = "sub"; + String UNDER = "underline"; + + addSegment("Bifurcation Diagram", "-fx-font-size:200%;", UNDER); + nl(2); + addSegment("In mathematics, particularly in dynamical systems, a ", null, GRAY); + addSegment("bifurcation diagram ", "-fx-font-weight:bold;"); // FIX does not work on mac + addSegment("shows the values visited or approached asymptotically (fixed points, periodic orbits, or chaotic attractors) of a system as a function of a bifurcation parameter in the system. It is usual to represent stable values with a solid line and unstable values with a dotted line, although often the unstable points are omitted. Bifurcation diagrams enable the visualization of bifurcation theory.", null, GRAY); + nl(2); + addSegment("An example is the bifurcation diagram of the logistic map:", null, GRAY); + nl(2); + addSegment(" x", EQ); + addSegment("n+1", null, EQ, SUB); + addSegment(" = λx", EQ); + addSegment("n", EQ, SUB); + addSegment("(1 - x", EQ); + addSegment("n", EQ, SUB); + addSegment(")", null, EQ); + setParagraphAttributes(StyleAttrs.of(OUTLINE, Boolean.TRUE)); + nl(2); + addSegment("The bifurcation parameter λ is shown on the horizontal axis of the plot and the vertical axis shows the set of values of the logistic function visited asymptotically from almost all initial conditions.", null, GRAY); + nl(2); + addSegment("The bifurcation diagram shows the forking of the periods of stable orbits from 1 to 2 to 4 to 8 etc. Each of these bifurcation points is a period-doubling bifurcation. The ratio of the lengths of successive intervals between values of r for which bifurcation occurs converges to the first Feigenbaum constant.", null, GRAY); + nl(2); + addSegment("The diagram also shows period doublings from 3 to 6 to 12 etc., from 5 to 10 to 20 etc., and so forth.", null, GRAY); + nl(); + addParagraph(BifurcationDiagram::generate); + nl(2); + addSegment("Source: Wikipedia"); + nl(); + addSegment("https://en.wikipedia.org/wiki/Bifurcation_diagram", null, GREEN, UNDER); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NotebookModel2.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NotebookModel2.java new file mode 100644 index 00000000000..a7f5f645b43 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NotebookModel2.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; +import javafx.scene.control.Button; +import javafx.scene.control.TextField; + +/** + * Mocks a Notebook Page that Provides a SQL Query Engine Interface + */ +public class NotebookModel2 extends SimpleViewOnlyStyledModel { + private final SimpleStringProperty query = new SimpleStringProperty(); + private final SimpleObjectProperty result = new SimpleObjectProperty<>(); + private static final String QUERY = "SELECT * FROM Book WHERE price > 100.00;"; + + public NotebookModel2() { + String ARABIC = "arabic"; + String CODE = "code"; + String RED = "red"; + String GREEN = "green"; + String UNDER = "underline"; + String GRAY = "gray"; + String LARGE = "large"; + String EQ = "equation"; + String SUB = "sub"; + + addSegment("SQL Select", "-fx-font-size:200%;", UNDER); + nl(2); + addSegment("The SQL ", null, GRAY); + addSegment("SELECT ", "-fx-font-weight:bold;"); // FIX does not work on mac + addSegment("statement returns a result set of records, from one or more tables.", null, GRAY); + nl(2); + addSegment("A SELECT statement retrieves zero or more rows from one or more database tables or database views. In most applications, SELECT is the most commonly used data manipulation language (DML) command. As SQL is a declarative programming language, SELECT queries specify a result set, but do not specify how to calculate it. The database translates the query into a \"query plan\" which may vary between executions, database versions and database software. This functionality is called the \"query optimizer\" as it is responsible for finding the best possible execution plan for the query, within applicable constraints.", null, GRAY); + nl(2); + addSegment(QUERY, "-fx-font-weight:bold;"); // FIX does not work on mac + nl(2); + addNodeSegment(() -> { + TextField f = new TextField(); + f.setPrefColumnCount(50); + f.textProperty().bindBidirectional(query); + return f; + }); + addSegment(" ", null, GRAY); + addNodeSegment(() -> { + Button b = new Button("Run"); + b.setOnAction((ev) -> execute()); + return b; + }); + nl(2); + addSegment("Result:", null, GRAY); + nl(); + addParagraph(() -> new ResultParagraph(result)); + nl(2); + addSegment("Source: Wikipedia"); + nl(); + addSegment("https://en.wikipedia.org/wiki/Select_(SQL)", null, GREEN, UNDER); + } + + protected void execute() { + String q = query.get().toLowerCase(); + if(q.equals(QUERY.toLowerCase())) { + result.set(generate()); + } else { + result.set("This query is not supported by the demo engine."); + } + } + + private String[] generate() { + return new String[] { + "Title|Author|Price", + "SQL Examples and Guide|J.Goodwell|145.55", + "The Joy of SQL|M.C.Eichler|250.00", + "An Introduction to SQL|Q.Adams|101.99", + }; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NotebookModelStacked.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NotebookModelStacked.java new file mode 100644 index 00000000000..1dde12a01dd --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/NotebookModelStacked.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import java.util.ArrayList; +import java.util.function.Supplier; +import javafx.scene.control.TextArea; +import javafx.scene.layout.Region; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.StyleResolver; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.PlainTextModel; +import jfx.incubator.scene.control.rich.model.RichParagraph; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +public class NotebookModelStacked extends StyledTextModel { + enum Type { + CODE, + COMMENT, + TEXTAREA, + } + + private final ArrayList paragraphs = new ArrayList<>(); + + public NotebookModelStacked() { + paragraphs.add(m1()); + paragraphs.add(Type.TEXTAREA); + paragraphs.add(m2()); + } + + public static StyledTextModel m1() { + return create(Type.COMMENT, "This is\na comment cell."); + } + + public static StyledTextModel m2() { + return create(Type.CODE, "x = 5;\nprint(x);"); + } + + public static StyledTextModel create(Type type, String text) { + PlainTextModel m; + switch(type) { + case CODE: + m = new PlainTextModel() { + @Override + public RichParagraph getParagraph(int index) { + String text = getPlainText(index); + RichParagraph.Builder b = RichParagraph.builder(); + b.addSegment(text, "-fx-text-fill:darkgreen; -fx-font-family:Monospace;", null); + return b.build(); + } + }; + break; + case COMMENT: + m = new PlainTextModel() { + @Override + public RichParagraph getParagraph(int index) { + String text = getPlainText(index); + RichParagraph.Builder b = RichParagraph.builder(); + b.addSegment(text, "-fx-text-fill:gray;", null); + return b.build(); + } + }; + break; + default: + throw new Error("?" + type); + } + + m.insertText(TextPos.ZERO, text); + return m; + } + + @Override + public boolean isUserEditable() { + return false; + } + + @Override + public int size() { + return paragraphs.size(); + } + + @Override + public String getPlainText(int index) { + return ""; + } + + @Override + public RichParagraph getParagraph(int index) { + Object x = paragraphs.get(index); + if(x instanceof StyledTextModel m) { + return RichParagraph.of(() -> { + RichTextArea t = new RichTextArea(m); + t.setMaxWidth(Double.POSITIVE_INFINITY); + t.setWrapText(true); + t.setUseContentHeight(true); + return t; + }); + } else if(x instanceof Type type) { + switch(type) { + case TEXTAREA: + return RichParagraph.of(() -> { + TextArea t = new TextArea(); + t.setMaxWidth(Double.POSITIVE_INFINITY); + t.setWrapText(true); + return t; + }); + } + } + throw new Error("?" + x); + } + + @Override + protected void removeRange(TextPos start, TextPos end) { + } + + @Override + protected int insertTextSegment(int index, int offset, String text, StyleAttrs attrs) { + return 0; + } + + @Override + protected void insertLineBreak(int index, int offset) { + } + + @Override + protected void insertParagraph(int index, Supplier generator) { + } + + @Override + public StyleAttrs getStyleAttrs(StyleResolver r, TextPos pos) { + return StyleAttrs.EMPTY; + } + + @Override + protected void setParagraphStyle(int ix, StyleAttrs paragraphAttrs) { + } + + @Override + protected void applyStyle(int ix, int start, int end, StyleAttrs a, boolean merge) { + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ParagraphAttributesDemoModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ParagraphAttributesDemoModel.java new file mode 100644 index 00000000000..3930fc1537e --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ParagraphAttributesDemoModel.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import jfx.incubator.scene.control.rich.model.RtfFormatHandler; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import com.oracle.demo.rich.util.FX; + +/** + * This simple, read-only StyledModel demonstrates various paragraph attributes. + */ +public class ParagraphAttributesDemoModel extends SimpleViewOnlyStyledModel { + private final static StyleAttrs TITLE = StyleAttrs.builder(). + setFontSize(24). + setUnderline(true). + build(); + private final static StyleAttrs BULLET = StyleAttrs.builder(). + setSpaceLeft(20). + setBullet("•"). + build(); + private final static StyleAttrs FIRST_LINE_INDENT = StyleAttrs.builder(). + setFirstLineIndent(100). + build(); + + public ParagraphAttributesDemoModel() { + registerDataFormatHandler(new RtfFormatHandler(), true, false, 1000); + insert(this); + } + + public static void insert(SimpleViewOnlyStyledModel m) { + m.addSegment("Bullet List", TITLE); + m.nl(2); + m.setParagraphAttributes(BULLET); + m.addSegment("This little piggy went to market,"); + m.setParagraphAttributes(BULLET); + m.nl(); + m.addSegment("This little piggy stayed home,"); + m.setParagraphAttributes(BULLET); + m.nl(); + m.addSegment("This little piggy had roast beef,"); + m.setParagraphAttributes(BULLET); + m.nl(); + m.addSegment("This little piggy had none."); + m.setParagraphAttributes(BULLET); + m.nl(); + m.addSegment("This little piggy went ..."); + m.setParagraphAttributes(BULLET); + m.nl(); + m.addSegment("Wee, wee, wee, all the way home!"); + m.setParagraphAttributes(BULLET); + m.nl(2); + + m.addSegment("First Line Indent", TITLE); + m.nl(2); + m.addSegment(words(60)); + m.setParagraphAttributes(FIRST_LINE_INDENT); + m.nl(2); + + m.addSegment("Paragraph Attributes", TITLE); + m.nl(2); + + m.addSegment("✓ Opaque Background Color"); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.LIGHTGREEN). + build()); + m.nl(); + + m.addSegment("✓ Translucent Background Color"); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(FX.alpha(Color.LIGHTGREEN, 0.5)). + build()); + m.nl(); + + // space + + m.addSegment("✓ Space Above"); + m.setParagraphAttributes(StyleAttrs.builder(). + setSpaceAbove(20). + setBackground(Color.gray(0.95, 0.5)). + setBullet("•"). + build()); + m.nl(); + + m.addSegment("✓ Space Below"); + m.setParagraphAttributes(StyleAttrs.builder(). + setSpaceBelow(20). + setBackground(Color.gray(0.9, 0.5)). + setBullet("◦"). + build()); + m.nl(); + + m.addSegment("✓ Space Left " + words(50)); + m.setParagraphAttributes(StyleAttrs.builder(). + setSpaceLeft(20). + setBackground(Color.gray(0.85, 0.5)). + setBullet("∙"). + build()); + m.nl(); + + m.addSegment("✓ Space Right " + words(10)); + m.setParagraphAttributes(StyleAttrs.builder(). + setSpaceRight(20). + setBackground(Color.gray(0.8, 0.5)). + setBullet("‣"). + build()); + m.nl(); + + // text alignment + + m.addSegment("✓ Text Alignment Left " + words(20)); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.gray(0.95, 0.5)). + setTextAlignment(TextAlignment.LEFT). + build()); + m.nl(); + + m.addSegment("✓ Text Alignment Right " + words(20)); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.gray(0.9, 0.5)). + setTextAlignment(TextAlignment.RIGHT). + build()); + m.nl(); + + m.addSegment("✓ Text Alignment Center " + words(20)); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.gray(0.85, 0.5)). + setTextAlignment(TextAlignment.CENTER). + build()); + m.nl(); + + m.addSegment("✓ Text Alignment Justify " + words(20)); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.gray(0.8, 0.5)). + setTextAlignment(TextAlignment.JUSTIFY). + build()); + m.nl(); + + // line spacing + + m.addSegment("✓ Line Spacing 0 " + words(200)); + m.highlight(50, 100, FX.alpha(Color.RED, 0.4)); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.gray(0.95, 0.5)). + setLineSpacing(0). + build()); + m.nl(); + + m.addSegment("✓ Line Spacing 20 " + words(200)); + m.highlight(50, 100, FX.alpha(Color.RED, 0.4)); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.gray(0.9, 0.5)). + setLineSpacing(20). + build()); + m.nl(); + + m.addSegment("✓ Line Spacing 40 " + words(200)); + m.highlight(50, 100, FX.alpha(Color.RED, 0.4)); + m.setParagraphAttributes(StyleAttrs.builder(). + setBackground(Color.gray(0.9, 0.5)). + setLineSpacing(40). + build()); + m.nl(); + } + + private static String words(int count) { + String[] lorem = { + "Lorem", + "ipsum", + "dolor", + "sit", + "amet,", + "consectetur", + "adipiscing", + "elit,", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua" + }; + + StringBuilder sb = new StringBuilder(); + for(int i=0; i 0) { + sb.append(' '); + } + sb.append(lorem[i % lorem.length]); + } + sb.append("."); + return sb.toString(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/PrefSizeTester.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/PrefSizeTester.java new file mode 100644 index 00000000000..f049512e2ea --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/PrefSizeTester.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.layout.Background; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; + +public class PrefSizeTester extends Pane { + private final ComboBox prefWidth; + private final ComboBox prefHeight; + private final GridPane p; + + public PrefSizeTester() { + setBackground(Background.fill(Color.LIGHTSTEELBLUE)); + + prefWidth = new ComboBox(); + prefWidth.getItems().addAll( + -1.0, + 100.0, + 200.0, + 300.0 + ); + prefWidth.setOnAction((ev) -> { + updateWidth(); + }); + + prefHeight = new ComboBox(); + prefHeight.getItems().addAll( + -1.0, + 100.0, + 200.0, + 300.0 + ); + prefHeight.setOnAction((ev) -> { + updateHeight(); + }); + + p = new GridPane(); + p.add(new Label("Pref Width:"), 0, 0); + p.add(prefWidth, 1, 0); + p.add(new Label("Pref Height:"), 0, 1); + p.add(prefHeight, 1, 1); + + getChildren().add(p); + //setCenter(p); + } + + private void updateWidth() { + if (prefWidth.getValue() instanceof Number n) { + double w = n.doubleValue(); + p.setPrefWidth(w); + } + } + + private void updateHeight() { + if (prefHeight.getValue() instanceof Number n) { + double h = n.doubleValue(); + p.setPrefHeight(h); + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ROptionPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ROptionPane.java new file mode 100644 index 00000000000..0c3888a50e5 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ROptionPane.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +/** + * + */ +public class ROptionPane extends GridPane { + private int row; + private int column; + private static final Insets MARGIN = new Insets(2, 4, 2, 4); + + public ROptionPane() { + // no such thing + // https://stackoverflow.com/questions/20454021/how-to-set-padding-between-columns-of-a-javafx-gridpane + // setVGap(2); + } + + public void label(String text) { + add(new Label(text)); + } + + public void option(Node n) { + add(n); + } + + public void add(Node n) { + add(n, column, row++); + setMargin(n, MARGIN); + setFillHeight(n, Boolean.TRUE); + setFillWidth(n, Boolean.TRUE); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RegionCellPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RegionCellPane.java new file mode 100644 index 00000000000..1e7322ab303 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RegionCellPane.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; + +/** + * Content pane for TextCell that shows an arbitrary Region. + * The content gets resized if it cannot fit into available width. + */ +public class RegionCellPane extends Pane { + private final Region content; + private static final Insets PADDING = new Insets(1, 1, 1, 1); + + public RegionCellPane(Region n) { + this.content = n; + + getChildren().add(n); + + setPadding(PADDING); + getStyleClass().add("region-cell"); + } + + @Override + protected void layoutChildren() { + double width = getWidth() - snappedLeftInset() - snappedRightInset(); + double w = content.prefWidth(-1); + if (w < width) { + width = w; + } + double h = content.prefHeight(width); + + double x0 = snappedLeftInset(); + double y0 = snappedTopInset(); + layoutInArea( + content, + x0, + y0, + width, + h, + 0, + null, + true, + false, + HPos.CENTER, + VPos.CENTER + ); + } + + @Override + protected double computePrefHeight(double width) { + return content.prefHeight(width) + snappedTopInset() + snappedBottomInset(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ResultParagraph.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ResultParagraph.java new file mode 100644 index 00000000000..95de84fde76 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/ResultParagraph.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.scene.Node; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.layout.BorderPane; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +public class ResultParagraph extends BorderPane { + SimpleObjectProperty result = new SimpleObjectProperty(); + + public ResultParagraph(SimpleObjectProperty src) { + result.bind(src); + result.addListener((s, p, c) -> { + update(); + }); + update(); + setPrefSize(600, 200); + } + + protected void update() { + Node n = getNode(); + setCenter(n); + } + + protected Node getNode() { + Object r = result.get(); + if (r instanceof String s) { + Text t = new Text(s); + t.setStyle("-fx-fill:red;"); + + TextFlow f = new TextFlow(); + f.getChildren().add(t); + return f; + } else if (r instanceof String[] ss) { + DataFrame f = DataFrame.parse(ss); + TableView t = new TableView<>(); + + String[] cols = f.getColumnNames(); + for (int i=0; i c = new TableColumn<>(col); + int ix = i; + c.setCellValueFactory((d) -> { + String[] row = d.getValue(); + String s = row[ix]; + return new SimpleStringProperty(s); + }); + t.getColumns().add(c); + } + for (int i = 0; i < f.getRowCount(); i++) { + t.getItems().add(f.getRow(i)); + } + t.prefWidthProperty().bind(widthProperty()); + return t; + } + return null; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemo.css b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemo.css new file mode 100644 index 00000000000..675bac64716 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemo.css @@ -0,0 +1,54 @@ +.red { + -fx-fill:red; +} + +.green { + -fx-fill:#3e8c25; +} + +.gray { + -fx-fill:gray; +} + +.code { + -fx-font-family:Monospace; +} + +.underline { + -fx-underline:true; +} + +.monospaced { + -fx-font-family:Monospaced; +} + +.bold { + -fx-font-weight: bold; +} + +.italic { + -fx-font-family: serif; + -fx-font-style: italic; +} + +.strikethrough { + -fx-strikethrough: true; +} + +.arabic { + -fx-font-family:Tahoma; + -fx-font-size:200%; +} + +.large { + -fx-font-size:200%; +} + +.equation { + -fx-font-family:serif; +} + +.sub { + -fx-text-origin:bottom; + -fx-font-size:70%; +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemoApp.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemoApp.java new file mode 100644 index 00000000000..f8a0b52bc2e --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemoApp.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import javafx.application.Application; +import javafx.stage.Stage; +import com.oracle.demo.rich.settings.FxSettings; + +/** + * RichTextArea Demo Application. + */ +public class RichTextAreaDemoApp extends Application { + public static void main(String[] args) { + Application.launch(RichTextAreaDemoApp.class, args); + } + + @Override + public void init() { + FxSettings.useDirectory(".RichTextAreaDemo"); + } + + @Override + public void start(Stage stage) throws Exception { + try { + new RichTextAreaWindow(false).show(); + } catch (Throwable e) { + e.printStackTrace(); + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemoPane.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemoPane.java new file mode 100644 index 00000000000..6b5cd08ef5c --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaDemoPane.java @@ -0,0 +1,696 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import java.nio.charset.Charset; +import java.util.Base64; +import java.util.Objects; +import javafx.application.Platform; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.ScrollPane.ScrollBarPolicy; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.SplitPane; +import javafx.scene.input.Clipboard; +import javafx.scene.input.DataFormat; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.TextAlignment; +import javafx.stage.Window; +import javafx.util.Duration; +import javafx.util.StringConverter; +import com.oracle.demo.rich.util.FX; +import jfx.incubator.scene.control.input.KeyBinding; +import jfx.incubator.scene.control.rich.LineNumberDecorator; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.SideDecorator; +import jfx.incubator.scene.control.rich.StyleHandlerRegistry; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.EditableRichTextModel; +import jfx.incubator.scene.control.rich.model.ParagraphDirection; +import jfx.incubator.scene.control.rich.model.StyleAttribute; +import jfx.incubator.scene.control.rich.model.StyleAttrs; +import jfx.incubator.scene.control.rich.model.StyledTextModel; + +/** + * Main Panel contains RichTextArea, split panes for quick size adjustment, and an option pane. + */ +public class RichTextAreaDemoPane extends BorderPane { + enum Decorator { + NULL, + LINE_NUMBERS, + COLORS + } + + private static StyledTextModel globalModel; + public final ROptionPane op; + public final RichTextArea control; + public final ComboBox modelField; + + public RichTextAreaDemoPane(boolean useContentSize) { + FX.name(this, "RichTextAreaDemoPane"); + + control = new RichTextArea() { + private static final StyleHandlerRegistry registry = init(); + + private static StyleHandlerRegistry init() { + // brings in the handlers from the base class + StyleHandlerRegistry.Builder b = StyleHandlerRegistry.builder(RichTextArea.styleHandlerRegistry); + // adds a handler for the new attribute + b.setParHandler(NotebookModel.OUTLINE, (c, cx, v) -> { + if (v) { + cx.addStyle("-fx-border-color:LIGHTPINK;"); + cx.addStyle("-fx-border-width:1;"); + } + }); + return b.build(); + } + + @Override + public StyleHandlerRegistry getStyleHandlerRegistry() { + return registry; + } + }; + control.setUseContentHeight(useContentSize); + control.setUseContentWidth(useContentSize); + control.setHighlightCurrentParagraph(true); + // custom function + control.getInputMap().register(KeyBinding.of(KeyCode.F2), () -> { + EditableRichTextModel.dump(control.getModel(), System.out); + }); + + Node contentNode; + if (useContentSize) { + contentNode = new ScrollPane(control); + } else { + contentNode = control; + } + + SplitPane hsplit = new SplitPane(contentNode, pane()); + FX.name(hsplit, "hsplit"); + hsplit.setBorder(null); + hsplit.setDividerPositions(1.0); + hsplit.setOrientation(Orientation.HORIZONTAL); + + SplitPane vsplit = new SplitPane(hsplit, pane()); + FX.name(vsplit, "vsplit"); + vsplit.setBorder(null); + vsplit.setDividerPositions(1.0); + vsplit.setOrientation(Orientation.VERTICAL); + + modelField = new ComboBox<>(); + FX.name(modelField, "modelField"); + modelField.getItems().setAll(ModelChoice.values()); + + CheckBox editable = new CheckBox("editable"); + FX.name(editable, "editable"); + editable.selectedProperty().bindBidirectional(control.editableProperty()); + + CheckBox wrapText = new CheckBox("wrap text"); + FX.name(wrapText, "wrapText"); + wrapText.selectedProperty().bindBidirectional(control.wrapTextProperty()); + + CheckBox displayCaret = new CheckBox("display caret"); + FX.name(displayCaret, "displayCaret"); + displayCaret.selectedProperty().bindBidirectional(control.displayCaretProperty()); + + CheckBox fatCaret = new CheckBox("fat caret"); + FX.name(fatCaret, "fatCaret"); + fatCaret.selectedProperty().addListener((s, p, on) -> { + Node n = control.lookup(".caret"); + if (n != null) { + if (on) { + n.setStyle("-fx-stroke-width:2; -fx-stroke:red; -fx-effect:dropshadow(gaussian,rgba(0,0,0,.5),5,0,1,1);"); + } else { + n.setStyle(null); + } + } + }); + + CheckBox fastBlink = new CheckBox("blink fast"); + FX.name(fastBlink, "fastBlink"); + fastBlink.selectedProperty().addListener((s,p,on) -> { + control.setCaretBlinkPeriod(on ? Duration.millis(200) : Duration.millis(500)); + }); + + CheckBox highlightCurrentLine = new CheckBox("highlight current line"); + FX.name(highlightCurrentLine, "highlightCurrentLine"); + highlightCurrentLine.selectedProperty().bindBidirectional(control.highlightCurrentParagraphProperty()); + + Button reloadModelButton = new Button("Reload Model"); + reloadModelButton.setOnAction((ev) -> reloadModel()); + + CheckBox customPopup = new CheckBox("custom popup menu"); + FX.name(customPopup, "customPopup"); + customPopup.selectedProperty().addListener((s, p, v) -> { + setCustomPopup(v); + }); + + ComboBox contentPadding = new ComboBox<>(); + FX.name(contentPadding, "contentPadding"); + contentPadding.setConverter(new StringConverter() { + @Override + public String toString(Insets x) { + if (x == null) { + return "null"; + } + return String.format( + "T%d, B%d, L%d, R%d", + (int)x.getTop(), + (int)x.getBottom(), + (int)x.getLeft(), + (int)x.getRight() + ); + } + + @Override + public Insets fromString(String s) { + return null; + } + }); + contentPadding.getItems().setAll( + null, + new Insets(1), + new Insets(2), + new Insets(10), + new Insets(22.22), + new Insets(50), + new Insets(100), + new Insets(5, 10, 15, 20) + ); + contentPadding.getSelectionModel().selectedItemProperty().addListener((s,p,v) -> { + control.setContentPadding(v); + }); + + ComboBox leftDecorator = new ComboBox<>(); + FX.name(leftDecorator, "leftDecorator"); + leftDecorator.getItems().setAll(Decorator.values()); + leftDecorator.getSelectionModel().selectedItemProperty().addListener((s,p,v) -> { + control.setLeftDecorator(createDecorator(v)); + }); + + ComboBox rightDecorator = new ComboBox<>(); + FX.name(rightDecorator, "rightDecorator"); + rightDecorator.getItems().setAll(Decorator.values()); + rightDecorator.getSelectionModel().selectedItemProperty().addListener((s,p,v) -> { + control.setRightDecorator(createDecorator(v)); + }); + + CheckBox trackWidth = new CheckBox("use content width"); + FX.name(trackWidth, "trackWidth"); + trackWidth.selectedProperty().bindBidirectional(control.useContentWidthProperty()); + + CheckBox trackHeight = new CheckBox("use content height"); + FX.name(trackHeight, "trackHeight"); + trackHeight.selectedProperty().bindBidirectional(control.useContentHeightProperty()); + + Button appendButton = new Button("Append"); + FX.tooltip(appendButton, "appends text to the end of the document"); + appendButton.setOnAction((ev) -> { + StyleAttrs heading = StyleAttrs.builder().setBold(true).setFontSize(24).build(); + StyleAttrs plain = StyleAttrs.builder().setFontFamily("Monospaced").build(); + control.appendText("Heading\n", heading); + control.appendText("Plain monospaced text.\n", plain); + }); + + Button insertButton = new Button("Insert"); + FX.tooltip(insertButton, "inserts text to the start of the document"); + insertButton.setOnAction((ev) -> { + StyleAttrs heading = StyleAttrs.builder().setBold(true).setFontSize(24).build(); + StyleAttrs plain = StyleAttrs.builder().setFontFamily("Monospaced").build(); + control.insertText(TextPos.ZERO, "Plain monospaced text.\n", plain); + control.insertText(TextPos.ZERO, "Heading\n", heading); + }); + + op = new ROptionPane(); + op.label("Model:"); + op.option(modelField); + op.option(new HBox(insertButton, appendButton)); + op.option(editable); + op.option(reloadModelButton); + op.option(wrapText); + op.option(displayCaret); + op.option(fatCaret); + op.option(fastBlink); + op.option(highlightCurrentLine); + op.option(customPopup); + op.label("Content Padding:"); + op.option(contentPadding); + op.label("Decorators:"); + op.option(leftDecorator); + op.option(rightDecorator); + op.option(trackWidth); + op.option(trackHeight); + + setCenter(vsplit); + + ScrollPane sp = new ScrollPane(op); + sp.setVbarPolicy(ScrollBarPolicy.AS_NEEDED); + sp.setHbarPolicy(ScrollBarPolicy.NEVER); + setRight(sp); + + modelField.getSelectionModel().selectFirst(); + contentPadding.getSelectionModel().selectFirst(); + leftDecorator.getSelectionModel().selectFirst(); + rightDecorator.getSelectionModel().selectFirst(); + + Platform.runLater(() -> { + // all this to make sure restore settings works correctly with second window loading the same model + if (globalModel == null) { + globalModel = createModel(); + } + control.setModel(globalModel); + + modelField.getSelectionModel().selectedItemProperty().addListener((s, p, c) -> { + updateModel(); + }); + }); + } + + protected SideDecorator createDecorator(Decorator d) { + if (d != null) { + switch (d) { + case COLORS: + return new DemoColorSideDecorator(); + case LINE_NUMBERS: + return new LineNumberDecorator(); + } + } + return null; + } + + protected void updateModel() { + globalModel = createModel(); + control.setModel(globalModel); + } + + protected void reloadModel() { + control.setModel(null); + updateModel(); + } + + private StyledTextModel createModel() { + ModelChoice m = modelField.getSelectionModel().getSelectedItem(); + return ModelChoice.create(m); + } + + protected static Pane pane() { + Pane p = new Pane(); + SplitPane.setResizableWithParent(p, false); + p.setStyle("-fx-background-color:#dddddd;"); + return p; + } + + public Button addButton(String name, Runnable action) { + Button b = new Button(name); + b.setOnAction((ev) -> { + action.run(); + }); + + toolbar().add(b); + return b; + } + + public TBar toolbar() { + if (getTop() instanceof TBar) { + return (TBar)getTop(); + } + + TBar t = new TBar(); + setTop(t); + return t; + } + + public Window getWindow() { + Scene s = getScene(); + if (s != null) { + return s.getWindow(); + } + return null; + } + + public void setOptions(Node n) { + setRight(n); + } + + protected String generateStylesheet(boolean fat) { + String s = ".rich-text-area .caret { -fx-stroke-width:" + (fat ? 2 : 1) + "; }"; + return "data:text/css;base64," + Base64.getEncoder().encodeToString(s.getBytes(Charset.forName("utf-8"))); + } + + protected void setCustomPopup(boolean on) { + if (on) { + ContextMenu m = new ContextMenu(); + m.getItems().add(new MenuItem("Dummy")); // otherwise no popup is shown + m.addEventFilter(Menu.ON_SHOWING, (ev) -> { + m.getItems().clear(); + populateCustomPopupMenu(m.getItems()); + }); + control.setContextMenu(m); + } else { + control.setContextMenu(null); + } + } + + protected void populateCustomPopupMenu(ObservableList items) { + boolean sel = control.hasNonEmptySelection(); + boolean paste = true; // would be easier with Actions (findFormatForPaste() != null); + boolean styled = (control.getModel() instanceof EditableRichTextModel); + + items.add(new MenuItem("★ Custom Context Menu")); + + items.add(new SeparatorMenuItem()); + + Menu m2; + MenuItem m; + CheckMenuItem cm; + items.add(m = new MenuItem("Undo")); + m.setOnAction((ev) -> control.undo()); + m.setDisable(!control.isUndoable()); + + items.add(m = new MenuItem("Redo")); + m.setOnAction((ev) -> control.redo()); + m.setDisable(!control.isRedoable()); + + items.add(new SeparatorMenuItem()); + + items.add(m = new MenuItem("Cut")); + m.setOnAction((ev) -> control.cut()); + m.setDisable(!sel); + + items.add(m = new MenuItem("Copy")); + m.setOnAction((ev) -> control.copy()); + m.setDisable(!sel); + + items.add(m = m2 = new Menu("Copy Special...")); + { + DataFormat[] fs = control.getModel().getSupportedDataFormats(true); + for (DataFormat f : fs) { + String name = f.toString(); + m2.getItems().add(m = new MenuItem(name)); + m.setOnAction((ev) -> control.copy(f)); + } + } + + items.add(m = new MenuItem("Paste")); + m.setOnAction((ev) -> control.paste()); + m.setDisable(!paste); + + items.add(m = m2 = new Menu("Paste Special...")); + m.setDisable(!paste); + { + DataFormat[] fs = control.getModel().getSupportedDataFormats(false); + for (DataFormat f : fs) { + if (Clipboard.getSystemClipboard().hasContent(f)) { + String name = f.toString(); + m2.getItems().add(m = new MenuItem(name)); + m2.setOnAction((ev) -> control.paste(f)); + m2.setDisable(!paste); + } + } + } + + items.add(m = new MenuItem("Paste and Match Style")); + m.setOnAction((ev) -> control.pastePlainText()); + m.setDisable(!paste); + + if (styled) { + StyleAttrs a = control.getActiveStyleAttrs(); + items.add(new SeparatorMenuItem()); + + items.add(m = new MenuItem("Bold")); + m.setOnAction((ev) -> applyStyle(StyleAttrs.BOLD, !a.getBoolean(StyleAttrs.BOLD))); + m.setDisable(!sel); + + items.add(m = new MenuItem("Italic")); + m.setOnAction((ev) -> applyStyle(StyleAttrs.ITALIC, !a.getBoolean(StyleAttrs.ITALIC))); + m.setDisable(!sel); + + items.add(m = new MenuItem("Strike Through")); + m.setOnAction((ev) -> applyStyle(StyleAttrs.STRIKE_THROUGH, !a.getBoolean(StyleAttrs.STRIKE_THROUGH))); + m.setDisable(!sel); + + items.add(m = new MenuItem("Underline")); + m.setOnAction((ev) -> applyStyle(StyleAttrs.UNDERLINE, !a.getBoolean(StyleAttrs.UNDERLINE))); + m.setDisable(!sel); + + items.add(m2 = new Menu("Text Color")); + colorMenu(m2, sel, Color.BLACK); + colorMenu(m2, sel, Color.DARKGRAY); + colorMenu(m2, sel, Color.GRAY); + colorMenu(m2, sel, Color.LIGHTGRAY); + colorMenu(m2, sel, Color.GREEN); + colorMenu(m2, sel, Color.RED); + colorMenu(m2, sel, Color.BLUE); + colorMenu(m2, sel, null); + + items.add(m2 = new Menu("Text Size")); + sizeMenu(m2, sel, 96); + sizeMenu(m2, sel, 72); + sizeMenu(m2, sel, 48); + sizeMenu(m2, sel, 36); + sizeMenu(m2, sel, 24); + sizeMenu(m2, sel, 18); + sizeMenu(m2, sel, 16); + sizeMenu(m2, sel, 14); + sizeMenu(m2, sel, 12); + sizeMenu(m2, sel, 10); + sizeMenu(m2, sel, 9); + sizeMenu(m2, sel, 8); + sizeMenu(m2, sel, 6); + + items.add(m2 = new Menu("Font Family")); + fontMenu(m2, sel, "System"); + fontMenu(m2, sel, "Serif"); + fontMenu(m2, sel, "Sans-serif"); + fontMenu(m2, sel, "Cursive"); + fontMenu(m2, sel, "Fantasy"); + fontMenu(m2, sel, "Monospaced"); + m2.getItems().add(new SeparatorMenuItem()); + fontMenu(m2, sel, "Arial"); + fontMenu(m2, sel, "Courier New"); + fontMenu(m2, sel, "Times New Roman"); + fontMenu(m2, sel, "null"); + } + + if (styled) { + StyleAttrs a = control.getActiveStyleAttrs(); + items.add(new SeparatorMenuItem()); + + items.add(m2 = new Menu("Alignment")); + alignmentMenu(m2, "Left", TextAlignment.LEFT); + alignmentMenu(m2, "Center", TextAlignment.CENTER); + alignmentMenu(m2, "Right", TextAlignment.RIGHT); + alignmentMenu(m2, "Justify", TextAlignment.JUSTIFY); + + items.add(m2 = new Menu("Line Spacing")); + lineSpacingMenu(m2, 0); + lineSpacingMenu(m2, 1); + lineSpacingMenu(m2, 10); + lineSpacingMenu(m2, 30); + + items.add(m2 = new Menu("Space")); + spaceMenu(m2, "All", 30, 30, 30, 30); + spaceMenu(m2, "Above", 30, 0, 0, 0); + spaceMenu(m2, "Below", 0, 0, 30, 0); + spaceMenu(m2, "Left", 0, 0, 0, 30); + spaceMenu(m2, "Right", 0, 30, 0, 0); + spaceMenu(m2, "None", 0, 0, 0, 0); + + items.add(m2 = new Menu("Paragraph Direction")); + directionMenu(m2, "Left-to-Right", ParagraphDirection.LEFT_TO_RIGHT); + directionMenu(m2, "Right-to-Left", ParagraphDirection.RIGHT_TO_LEFT); + directionMenu(m2, "", null); + + items.add(m2 = new Menu("Background Color")); + backgroundMenu(m2, "Red", Color.RED, 0.2); + backgroundMenu(m2, "Green", Color.GREEN, 0.2); + backgroundMenu(m2, "Blue", Color.BLUE, 0.2); + backgroundMenu(m2, "Gray", Color.GRAY, 1.0); + backgroundMenu(m2, "Gray 10%", Color.GRAY, 0.1); + backgroundMenu(m2, "Gray 20%", Color.GRAY, 0.2); + backgroundMenu(m2, "Yellow", Color.YELLOW, 1.0); + + items.add(m2 = new Menu("Bullet")); + bulletMenu(m2, a, "None", null); + bulletMenu(m2, a, "●", "●"); + bulletMenu(m2, a, "○", "○"); + bulletMenu(m2, a, "♣", "♣"); + + items.add(m2 = new Menu("First Line Indent")); + firstLineIndentMenu(m2, a, 0); + firstLineIndentMenu(m2, a, 10); + firstLineIndentMenu(m2, a, 50); + firstLineIndentMenu(m2, a, 100); + } + + items.add(new SeparatorMenuItem()); + + items.add(m = new MenuItem("Select All")); + m.setOnAction((ev) -> control.selectAll()); + } + + private void bulletMenu(Menu menu, StyleAttrs a, String name, String bullet) { + CheckMenuItem m = new CheckMenuItem(name); + menu.getItems().add(m); + m.setSelected(Objects.equals(bullet, a.getBullet())); + m.setOnAction((ev) -> { + applyStyle(StyleAttrs.BULLET, bullet); + }); + } + + private void firstLineIndentMenu(Menu menu, StyleAttrs a, int value) { + CheckMenuItem m = new CheckMenuItem(String.valueOf(value)); + menu.getItems().add(m); + Double v = a.getFirstLineIndent(); + if (v != null) { + m.setSelected(Objects.equals(value, v.intValue())); + } + m.setOnAction((ev) -> { + applyStyle(StyleAttrs.FIRST_LINE_INDENT, (double)value); + }); + } + + private void alignmentMenu(Menu menu, String name, TextAlignment a) { + MenuItem m = new MenuItem(name); + menu.getItems().add(m); + m.setOnAction((ev) -> { + applyStyle(StyleAttrs.TEXT_ALIGNMENT, a); + }); + } + + private void lineSpacingMenu(Menu menu, double value) { + MenuItem m = new MenuItem(String.valueOf(value)); + menu.getItems().add(m); + m.setOnAction((ev) -> { + applyStyle(StyleAttrs.LINE_SPACING, value); + }); + } + + private void directionMenu(Menu menu, String text, ParagraphDirection d) { + MenuItem m = new MenuItem(text); + menu.getItems().add(m); + m.setOnAction((ev) -> { + applyStyle(StyleAttrs.PARAGRAPH_DIRECTION, d); + }); + } + + private void spaceMenu(Menu menu, String name, double top, double right, double bottom, double left) { + MenuItem m = new MenuItem(name); + menu.getItems().add(m); + m.setOnAction((ev) -> { + applyStyle(StyleAttrs.SPACE_ABOVE, top); + applyStyle(StyleAttrs.SPACE_BELOW, bottom); + applyStyle(StyleAttrs.SPACE_LEFT, left); + applyStyle(StyleAttrs.SPACE_RIGHT, right); + }); + } + + private void backgroundMenu(Menu menu, String name, Color color, double alpha) { + Color c = FX.alpha(color, alpha); + MenuItem m = new MenuItem(name); + menu.getItems().add(m); + m.setOnAction((ev) -> { + applyStyle(StyleAttrs.BACKGROUND, c); + }); + } + + private void fontMenu(Menu menu, boolean selected, String family) { + MenuItem m = new MenuItem(family); + m.setDisable(!selected); + m.setOnAction((ev) -> applyStyle(StyleAttrs.FONT_FAMILY, family)); + menu.getItems().add(m); + } + + private void sizeMenu(Menu menu, boolean selected, double size) { + MenuItem m = new MenuItem(String.valueOf(size)); + m.setDisable(!selected); + m.setOnAction((ev) -> applyStyle(StyleAttrs.FONT_SIZE, size)); + menu.getItems().add(m); + } + + private void colorMenu(Menu menu, boolean selected, Color color) { + int w = 16; + int h = 16; + Canvas c = new Canvas(w, h); + GraphicsContext g = c.getGraphicsContext2D(); + if (color != null) { + g.setFill(color); + g.fillRect(0, 0, w, h); + } + g.setStroke(Color.DARKGRAY); + g.strokeRect(0, 0, w, h); + + MenuItem m = new MenuItem(null, c); + m.setDisable(!selected); + m.setOnAction((ev) -> applyStyle(StyleAttrs.TEXT_COLOR, color)); + menu.getItems().add(m); + } + + private void applyStyle(StyleAttribute a, T val) { + TextPos ca = control.getCaretPosition(); + TextPos an = control.getAnchorPosition(); + StyleAttrs m = StyleAttrs.of(a, val); + control.applyStyle(ca, an, m); + } + + /** Tool Bar */ + public static class TBar extends HBox { + public TBar() { + setFillHeight(true); + setAlignment(Pos.CENTER_LEFT); + setSpacing(2); + } + + public T add(T n) { + getChildren().add(n); + return n; + } + + public void addAll(Node... nodes) { + for (Node n : nodes) { + add(n); + } + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaWindow.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaWindow.java new file mode 100644 index 00000000000..6ac054b0e6c --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/RichTextAreaWindow.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.NodeOrientation; +import javafx.scene.Scene; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.MenuBar; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import com.oracle.demo.rich.util.FX; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.TextPos; + +/** + * Rich Text Area Demo window + */ +public class RichTextAreaWindow extends Stage { + public final RichTextAreaDemoPane demoPane; + public final Label status; + + public RichTextAreaWindow(boolean useContentSize) { + demoPane = new RichTextAreaDemoPane(useContentSize); + + CheckMenuItem orientation = new CheckMenuItem("Orientation: RTL"); + orientation.setOnAction((ev) -> { + NodeOrientation v = (orientation.isSelected()) ? + NodeOrientation.RIGHT_TO_LEFT : + NodeOrientation.LEFT_TO_RIGHT; + getScene().setNodeOrientation(v); + }); + // TODO save orientation in settings + + MenuBar mb = new MenuBar(); + // file + FX.menu(mb, "_File"); + FX.item(mb, "New Window", () -> newWindow(false)); + FX.item(mb, "New Window, Use Content Size", () -> newWindow(true)); + FX.separator(mb); + FX.item(mb, "Close Window", this::hide); + FX.separator(mb); + FX.item(mb, "Quit", Platform::exit); + // tests + FX.menu(mb, "_Tests"); + FX.item(mb, "Stacked Vertically", () -> openMultipeStacked(true)); + FX.item(mb, "Stacked Horizontally", () -> openMultipeStacked(false)); + FX.item(mb, "In a VBox", this::openInVBox); + // window + FX.menu(mb, "_Window"); + FX.item(mb, orientation); + // tools + FX.menu(mb, "T_ools"); + FX.item(mb, "CSS Tool", this::openCssTool); + + status = new Label(); + status.setPadding(new Insets(2, 10, 2, 10)); + + BorderPane bp = new BorderPane(); + bp.setTop(mb); + bp.setCenter(demoPane); + bp.setBottom(status); + + Scene scene = new Scene(bp); + scene.getStylesheets().addAll( + RichTextAreaWindow.class.getResource("RichTextAreaDemo.css").toExternalForm() + ); + + setScene(scene); + setTitle( + "RichTextArea Tester FX:" + + System.getProperty("javafx.runtime.version") + + " JDK:" + + System.getProperty("java.version") + ); + setWidth(1200); + setHeight(600); + + demoPane.control.caretPositionProperty().addListener((x) -> updateStatus()); + } + + protected void updateStatus() { + RichTextArea t = demoPane.control; + TextPos p = t.getCaretPosition(); + + StringBuilder sb = new StringBuilder(); + + if (p != null) { + sb.append(" line=").append(p.index()); + sb.append(" col=").append(p.offset()); + sb.append(p.isLeading() ? " (leading)" : ""); + } + + status.setText(sb.toString()); + } + + protected void newWindow(boolean useContentSize) { + double offset = 20; + + RichTextAreaWindow w = new RichTextAreaWindow(useContentSize); + w.setX(getX() + offset); + w.setY(getY() + offset); + w.setWidth(getWidth()); + w.setHeight(getHeight()); + w.show(); + } + + protected void openMultipeStacked(boolean vertical) { + new MultipleStackedBoxWindow(vertical).show(); + } + + protected void openInVBox() { + RichTextArea richTextArea = new RichTextArea(); + + VBox vb = new VBox(); + vb.getChildren().add(richTextArea); + + Stage w = new Stage(); + w.setScene(new Scene(vb)); + w.setTitle("RichTextArea in a VBox"); + w.show(); + } + + protected void openCssTool() { + Stage w = new Stage(); + w.setScene(new Scene(new CssToolPane())); + w.setTitle("CSS Tool"); + w.setWidth(800); + w.setHeight(600); + w.show(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/UnevenStyledTextModel.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/UnevenStyledTextModel.java new file mode 100644 index 00000000000..83ad43969d1 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/UnevenStyledTextModel.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +import java.util.Random; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; + +public class UnevenStyledTextModel extends SimpleViewOnlyStyledModel { + private Random r = new Random(); + + public UnevenStyledTextModel(int lineCount) { + float longLineProbability = 0.1f; + for (int i = 0; i < lineCount; i++) { + boolean large = (r.nextFloat() < longLineProbability); + addSegment((large ? "L." : "S.") + (i + 1)); + + if (large) { + add(1000); + } else { + add(10); + } + nl(); + } + } + + private void add(int count) { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < count; i++) { + int len = r.nextInt(10) + 1; + sb.append(' '); + sb.append(i); + sb.append('.'); + + for (int j = 0; j < len; j++) { + sb.append('*'); + } + } + + addSegment(sb.toString()); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/UsageExamples.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/UsageExamples.java new file mode 100644 index 00000000000..072b3d0ac6d --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/UsageExamples.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package com.oracle.demo.rich.rta; + +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.input.KeyCode; +import javafx.stage.Stage; +import jfx.incubator.scene.control.input.FunctionTag; +import jfx.incubator.scene.control.input.KeyBinding; +import jfx.incubator.scene.control.rich.CodeArea; +import jfx.incubator.scene.control.rich.LineNumberDecorator; +import jfx.incubator.scene.control.rich.RichTextArea; +import jfx.incubator.scene.control.rich.TextPos; +import jfx.incubator.scene.control.rich.model.SimpleViewOnlyStyledModel; +import jfx.incubator.scene.control.rich.model.StyleAttrs; + +/** + * The usage examples used in the documentation. + */ +public class UsageExamples { + void createViewOnly() { + SimpleViewOnlyStyledModel m = new SimpleViewOnlyStyledModel(); + // add text segment using CSS style name (requires a style sheet) + m.addSegment("RichTextArea ", null, "HEADER"); + // add text segment using direct style + m.addSegment("Demo", "-fx-font-size:200%;"); + // newline + m.nl(); + + RichTextArea textArea = new RichTextArea(m); + } + + static RichTextArea appendStyledText() { + // create styles + StyleAttrs heading = StyleAttrs.builder().setBold(true).setUnderline(true).setFontSize(18).build(); + StyleAttrs mono = StyleAttrs.builder().setFontFamily("Monospaced").build(); + + RichTextArea textArea = new RichTextArea(); + // build the content + textArea.appendText("RichTextArea\n", heading); + textArea.appendText("Example:\nText is ", StyleAttrs.EMPTY); + textArea.appendText("monospaced.\n", mono); + return textArea; + } + + void richTextAreaExample() { + RichTextArea textArea = new RichTextArea(); + // insert two paragraphs "A" and "B" + StyleAttrs bold = StyleAttrs.builder().setBold(true).build(); + textArea.appendText("A\nB", bold); + } + + private static CodeArea codeAreaExample() { + CodeArea codeArea = new CodeArea(); + codeArea.setWrapText(true); + codeArea.setLineNumbersEnabled(true); + codeArea.setText("Lorem\nIpsum"); + return codeArea; + } + + private static final FunctionTag PRINT_TO_CONSOLE = new FunctionTag(); + + void customNavigation() { + RichTextArea richTextArea = new RichTextArea(); + + // creates a new key binding mapped to an external function + richTextArea.getInputMap().register(KeyBinding.shortcut(KeyCode.W), () -> { + System.out.println("console!"); + }); + + // unbind old key bindings + var old = richTextArea.getInputMap().getKeyBindingsFor(RichTextArea.Tags.PASTE_PLAIN_TEXT); + for (KeyBinding k : old) { + richTextArea.getInputMap().unbind(k); + } + // map a new key binding + richTextArea.getInputMap().registerKey(KeyBinding.shortcut(KeyCode.W), RichTextArea.Tags.PASTE_PLAIN_TEXT); + + // redefine a function + richTextArea.getInputMap().registerFunction(RichTextArea.Tags.PASTE_PLAIN_TEXT, () -> { }); + richTextArea.pastePlainText(); // becomes a no-op + // revert back to the default behavior + richTextArea.getInputMap().restoreDefaultFunction(RichTextArea.Tags.PASTE_PLAIN_TEXT); + + // sets a side decorator + richTextArea.setLeftDecorator(new LineNumberDecorator()); + + richTextArea.getInputMap().registerFunction(PRINT_TO_CONSOLE, () -> { + // new functionality + System.out.println("PRINT_TO_CONSOLE executed"); + }); + + // change the functionality of an existing key binding + richTextArea.getInputMap().registerFunction(RichTextArea.Tags.MOVE_WORD_NEXT_START, () -> { + // refers to custom logic + TextPos p = getCustomNextWordPosition(richTextArea); + richTextArea.select(p); + }); + } + + void testGeneric() { + MyControl c = new MyControl(); + c.getInputMap().registerFunction(MyControl.MY_TAG, () -> { + c.newFunctionImpl(); + }); + } + + private TextPos getCustomNextWordPosition(RichTextArea richTextArea) { + return null; + } + + public static class MyControl extends RichTextArea { + // function tag allows the user to customize key bindings + public static final FunctionTag MY_TAG = new FunctionTag(); + + public MyControl() { + // register custom functionality with the input map + getInputMap().registerFunction(MY_TAG, this::newFunctionImpl); + // create a key binding + getInputMap().registerKey(KeyBinding.shortcut(KeyCode.W), MY_TAG); + } + + public void newFunctionImpl() { + // custom functionality + } + } + + public static class App extends Application { + public App() { + System.out.println("test app: F1 appends at the end, F2 inserts at the start, F3 clears selection."); + } + + @Override + public void start(Stage stage) throws Exception { + RichTextArea t = true ? appendStyledText() : codeAreaExample(); + stage.setScene(new Scene(t)); + t.selectionProperty().addListener((s,p,c) -> { + System.out.println("selection: " + c); + }); + t.anchorPositionProperty().addListener((s,p,c) -> { + System.out.println("anchor: " + c); + }); + t.caretPositionProperty().addListener((s,p,c) -> { + System.out.println("caret: " + c); + }); + t.getInputMap().register(KeyBinding.of(KeyCode.F1), () -> { + t.insertText(TextPos.ZERO, "F1", StyleAttrs.EMPTY); + }); + t.getInputMap().register(KeyBinding.of(KeyCode.F2), () -> { + t.insertText(TextPos.ZERO, "\n", StyleAttrs.EMPTY); + }); + t.getInputMap().register(KeyBinding.of(KeyCode.F3), () -> { + t.clearSelection(); + }); + stage.show(); + } + } + + public static void main(String[] args) { + App.launch(App.class, args); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/WritingSystemsDemo.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/WritingSystemsDemo.java new file mode 100644 index 00000000000..62e7c776b50 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/WritingSystemsDemo.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.rta; + +/** + * Sample text for testing writing systems support. + * See https://en.wikipedia.org/wiki/List_of_writing_systems + */ +public class WritingSystemsDemo { + public static final String[] PAIRS = { + "Arabic", "العربية", + "Aramaic", "Classical Syriac: ܐܪܡܝܐ, Old Aramaic: 𐤀𐤓𐤌𐤉𐤀; Imperial Aramaic: 𐡀𐡓𐡌𐡉𐡀; Jewish Babylonian Aramaic: אֲרָמִית‎", + "Akkadian", "𒀝𒅗𒁺𒌑", + "Armenian", "հայերէն/հայերեն", + "Assamese", "অসমীয়া", + "Awadhi", "अवधी/औधी", + "Azerbaijanis", "آذربایجانلیلار", + "Bagheli", "बघेली", + "Bagri", "बागड़ी, باگڑی", + "Bengali", "বাংলা", + "Bhojpuri", "𑂦𑂷𑂔𑂣𑂳𑂩𑂲", + "Braille", "⠃⠗⠇", + "Bundeli", "बुन्देली", + "Burmese", "မြန်မာ", + "Cherokee", "ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ", + "Chhattisgarhi", "छत्तीसगढ़ी, ଛତିଶଗଡ଼ି, ଲରିଆ", + "Chinese", "中文", + "Czech", "Čeština", + "Devanagari", "देवनागरी", + "Dhivehi", "ދިވެހި", + "Dhundhari", "ढूण्ढाड़ी/ઢૂણ્ઢાડ઼ી", + "Farsi", "فارسی", + "Garhwali", "गढ़वळि", + "Geʽez", "ግዕዝ", + "Greek", "Ελληνικά", + "Georgian", "ქართული", + "Gujarati", "ગુજરાતી", + "Harauti", "हाड़ौती, हाड़ोती", + "Haryanvi", "हरयाणवी", + "Hausa", "هَرْشٜن هَوْسَ", + "Hebrew", "עברית", + "Hindi", "हिन्दी", + "Inuktitut", "ᐃᓄᒃᑎᑐᑦ", + "Japanese", "日本語 かな カナ", + "Kangri", "कांगड़ी", + "Kannada", "ಕನ್ನಡ", + "Kashmiri", "كٲشُرकॉशुर𑆑𑆳𑆯𑆶𑆫𑇀", + "Khmer", "ខ្មែរ", + "Khortha", "खोरठा", + "Khowar", "کھووار زبان", + "Korean", "한국어", + "Kumaoni", "कुमाऊँनी", + "Kurdish", "Kurdî / کوردی", + "Magahi", "𑂧𑂏𑂯𑂲/𑂧𑂏𑂡𑂲", + "Maithili", "मैथिली", + "Malayalam", "മലയാളം", + "Malvi", "माळवी भाषा / માળવી ભાષા", + "Marathi", "मराठी", + "Marwari,", "मारवाड़ी", + "Meitei", "ꯃꯩꯇꯩꯂꯣꯟ", + "Mewari", "मेवाड़ी/મેવ઼ાડ઼ી", + "Mongolian", "ᠨᠢᠷᠤᠭᠤ", + "Nimadi", "निमाड़ी", + "Odia", "ଓଡ଼ିଆ", + "Pahari", "पहाड़ी پہاڑی ", + "Pashto", "پښتو", + "Punjabi", "ਪੰਜਾਬੀپن٘جابی", + "Rajasthani", "राजस्थानी", + "Russian", "Русский", + "Sanskrit", "संस्कृत-, संस्कृतम्", + "Santali", "ᱥᱟᱱᱛᱟᱲᱤ", + "Sindhi", "سِنڌِي‎ • सिन्धी", + "Suret", "ܣܘܪܝܬ", + "Surgujia", "सरगुजिया", + "Surjapuri", "सुरजापुरी, সুরজাপুরী", + "Tamil", "தமிழ்", + "Telugu", "తెలుగు", + "Thaana", "ދިވެހި", + "Thai", "ไทย", + "Tibetan", "བོད་", + "Tulu", "ತುಳು", + "Turoyo", "ܛܘܪܝܐ", + "Ukrainian", "Українська", + "Urdu", "اردو", + "Vietnamese", "Tiếng Việt", + "Yiddish", "ייִדיש יידיש אידיש" + }; + + public static String getText() { + return getText(false); + } + + public static String getText(boolean showUnicode) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < PAIRS.length;) { + String a = PAIRS[i++]; + String b = PAIRS[i++]; + t(sb, a, b, showUnicode); + } + return sb.toString(); + } + + private static void t(StringBuilder sb, String name, String text, boolean showUnicode) { + sb.append(name); + sb.append(": "); + sb.append(text); + if (showUnicode) { + sb.append(" ("); + native2ascii(sb, text); + sb.append(")"); + } + sb.append("\n"); + } + + private static void native2ascii(StringBuilder sb, String text) { + for (char c: text.toCharArray()) { + if (c < 0x20) { + escape(sb, c); + } else if (c > 0x7f) { + escape(sb, c); + } else { + sb.append(c); + } + } + } + + private static void escape(StringBuilder sb, char c) { + sb.append("\\u"); + sb.append(h(c >> 12)); + sb.append(h(c >> 8)); + sb.append(h(c >> 4)); + sb.append(h(c)); + } + + private static char h(int d) { + return "0123456789abcdef".charAt(d & 0x000f); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/animated.gif b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/animated.gif new file mode 100644 index 00000000000..b502ee1b7d3 Binary files /dev/null and b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/rta/animated.gif differ diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettings.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettings.java new file mode 100644 index 00000000000..d4a49346142 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettings.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxDock +package com.oracle.demo.rich.settings; + +import java.io.File; +import java.io.IOException; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.collections.ListChangeListener; +import javafx.scene.Node; +import javafx.stage.Modality; +import javafx.stage.PopupWindow; +import javafx.stage.Stage; +import javafx.stage.Window; +import javafx.util.Duration; + +/** + * This facility coordinates saving UI settings to and from persistent media. + * All the calls, except useProvider(), are expected to happen in an FX application thread. + * + * When using {@link FxSettingsFileProvider}, the settings file "ui-settings.properties" + * is placed in the specified directory in the user home. + * + * TODO handle i/o errors - set handler? + */ +public class FxSettings { + public static final boolean LOG = Boolean.getBoolean("FxSettings.LOG"); + private static final Duration SAVE_DELAY = Duration.millis(100); + private static ISettingsProvider provider; + private static boolean save; + private static Timeline saveTimer; + + /** call this in Application.init() */ + public static synchronized void useProvider(ISettingsProvider p) { + if (provider != null) { + throw new IllegalArgumentException("provider is already set"); + } + + provider = p; + + Window.getWindows().addListener((ListChangeListener.Change ch) -> { + while (ch.next()) { + if (ch.wasAdded()) { + for (Window w: ch.getAddedSubList()) { + handleWindowOpening(w); + } + } else if (ch.wasRemoved()) { + for (Window w: ch.getRemoved()) { + handleWindowClosing(w); + } + } + } + }); + + try { + provider.load(); + } catch (IOException e) { + throw new Error(e); + } + + saveTimer = new Timeline(new KeyFrame(SAVE_DELAY, (ev) -> save())); + } + + public static void useDirectory(String dir) { + File d = new File(System.getProperty("user.home"), dir); + useProvider(new FxSettingsFileProvider(d)); + } + + public static void setName(Window w, String name) { + // TODO + } + + private static void handleWindowOpening(Window w) { + if (w instanceof PopupWindow) { + return; + } + + if (w instanceof Stage s) { + if (s.getModality() != Modality.NONE) { + return; + } + } + + restoreWindow(w); + } + + public static void restoreWindow(Window w) { + WindowMonitor m = WindowMonitor.getFor(w); + if (m != null) { + FxSettingsSchema.restoreWindow(m, w); + + Node p = w.getScene().getRoot(); + FxSettingsSchema.restoreNode(p); + } + } + + private static void handleWindowClosing(Window w) { + if (w instanceof PopupWindow) { + return; + } + + storeWindow(w); + + boolean last = WindowMonitor.remove(w); + if (last) { + if (saveTimer != null) { + saveTimer.stop(); + save(); + } + } + } + + public static void storeWindow(Window w) { + WindowMonitor m = WindowMonitor.getFor(w); + if (m != null) { + FxSettingsSchema.storeWindow(m, w); + + Node p = w.getScene().getRoot(); + FxSettingsSchema.storeNode(p); + } + } + + public static void set(String key, String value) { + if (provider != null) { + provider.set(key, value); + triggerSave(); + } + } + + public static String get(String key) { + if (provider == null) { + return null; + } + return provider.get(key); + } + + public static void setStream(String key, SStream s) { + if (provider != null) { + provider.set(key, s); + triggerSave(); + } + } + + public static SStream getStream(String key) { + if (provider == null) { + return null; + } + return provider.getSStream(key); + } + + public static void setInt(String key, int value) { + set(key, String.valueOf(value)); + } + + public static int getInt(String key, int defaultValue) { + String v = get(key); + if (v != null) { + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + } + } + return defaultValue; + } + + public static void setBoolean(String key, boolean value) { + set(key, String.valueOf(value)); + } + + public static Boolean getBoolean(String key) { + String v = get(key); + if (v != null) { + if ("true".equals(v)) { + return Boolean.TRUE; + } else if ("false".equals(v)) { + return Boolean.FALSE; + } + } + return null; + } + + private static synchronized void triggerSave() { + save = true; + if (saveTimer != null) { + saveTimer.stop(); + saveTimer.play(); + } + } + + private static void save() { + try { + save = false; + provider.save(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void restore(Node n) { + FxSettingsSchema.restoreNode(n); + } + + public static void store(Node n) { + FxSettingsSchema.storeNode(n); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettingsFileProvider.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettingsFileProvider.java new file mode 100644 index 00000000000..006f19bd47d --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettingsFileProvider.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxDock +package com.oracle.demo.rich.settings; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; + +/** + * Settings provider stores settings as a single file in the specified directory. + */ +public class FxSettingsFileProvider implements ISettingsProvider { + private static final char SEP = '='; + private static final String DIV = ","; + private final File file; + private final HashMap data = new HashMap<>(); + + public FxSettingsFileProvider(File dir) { + file = new File(dir, "ui-settings.properties"); + } + + @Override + public void load() throws IOException { + if (file.exists() && file.isFile()) { + Charset cs = Charset.forName("utf-8"); + try (BufferedReader rd = new BufferedReader(new InputStreamReader(new FileInputStream(file), cs))) { + synchronized (data) { + read(rd); + } + } + } + } + + @Override + public void save() throws IOException { + if (file.getParentFile() != null) { + file.getParentFile().mkdirs(); + } + + Charset cs = Charset.forName("utf-8"); + try (Writer wr = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), cs))) { + synchronized (data) { + write(wr); + } + } + } + + private void read(BufferedReader rd) throws IOException { + String s; + while ((s = rd.readLine()) != null) { + int ix = s.indexOf(SEP); + if (ix <= 0) { + continue; + } + String k = s.substring(0, ix); + String v = s.substring(ix + 1); + data.put(k, v); + } + } + + private void write(Writer wr) throws IOException { + ArrayList keys = new ArrayList<>(data.keySet()); + Collections.sort(keys); + + for (String k: keys) { + Object v = data.get(k); + wr.write(k); + wr.write(SEP); + wr.write(encode(v)); + wr.write("\r\n"); + } + } + + @Override + public void set(String key, String value) { + if (FxSettings.LOG) { + System.out.println("FxSettingsFileProvider.set key=" + key + " value=" + value); + } + synchronized (data) { + if (value == null) { + data.remove(key); + } else { + data.put(key, value); + } + } + } + + @Override + public void set(String key, SStream stream) { + if (FxSettings.LOG) { + System.out.println("FxSettingsFileProvider.set key=" + key + " stream=" + stream); + } + synchronized (data) { + if (stream == null) { + data.remove(key); + } else { + data.put(key, stream.toArray()); + } + } + } + + @Override + public String get(String key) { + Object v; + synchronized (data) { + v = data.get(key); + } + + String s; + if (v instanceof String) { + s = (String)v; + } else { + s = null; + } + + if (FxSettings.LOG) { + System.out.println("FxSettingsFileProvider.get key=" + key + " value=" + s); + } + return s; + } + + @Override + public SStream getSStream(String key) { + SStream s; + synchronized (data) { + Object v = data.get(key); + if (v instanceof Object[]) { + s = SStream.reader((Object[])v); + } else if (v != null) { + s = parseStream(v.toString()); + data.put(key, s.toArray()); + } else { + s = null; + } + } + + if (FxSettings.LOG) { + System.out.println("FxSettingsFileProvider.get key=" + key + " stream=" + s); + } + return s; + } + + private static SStream parseStream(String text) { + String[] ss = text.split(DIV); + return SStream.reader(ss); + } + + private static String encode(Object x) { + if (x == null) { + return ""; + } else if (x instanceof Object[] items) { + StringBuilder sb = new StringBuilder(); + boolean sep = false; + for (Object item: items) { + if (sep) { + sb.append(DIV); + } else { + sep = true; + } + sb.append(item); + } + return sb.toString(); + } else { + return x.toString(); + } + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettingsSchema.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettingsSchema.java new file mode 100644 index 00000000000..6ad1e9ebdc0 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/FxSettingsSchema.java @@ -0,0 +1,478 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxDock +package com.oracle.demo.rich.settings; + +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Rectangle2D; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.DialogPane; +import javafx.scene.control.ListView; +import javafx.scene.control.MenuBar; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.SplitPane; +import javafx.scene.image.ImageView; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.TilePane; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Shape; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * Constants and methods used to persist settings. + */ +public class FxSettingsSchema { + private static final String PREFIX = "FX."; + private static final String WINDOW_NORMAL = "N"; + private static final String WINDOW_ICONIFIED = "I"; + private static final String WINDOW_MAXIMIZED = "M"; + private static final String WINDOW_FULLSCREEN = "F"; + private static final Object NAME_PROP = new Object(); + + public static void storeWindow(WindowMonitor m, Window w) { + SStream ss = SStream.writer(); + ss.add(m.getX()); + ss.add(m.getY()); + ss.add(m.getWidth()); + ss.add(m.getHeight()); + if (w instanceof Stage s) { + if (s.isIconified()) { + ss.add(WINDOW_ICONIFIED); + } else if (s.isMaximized()) { + ss.add(WINDOW_MAXIMIZED); + } else if (s.isFullScreen()) { + ss.add(WINDOW_FULLSCREEN); + } else { + ss.add(WINDOW_NORMAL); + } + } + FxSettings.setStream(PREFIX + m.getID(), ss); + } + + public static void restoreWindow(WindowMonitor m, Window win) { + SStream ss = FxSettings.getStream(PREFIX + m.getID()); + if (ss == null) { + return; + } + + double x = ss.nextDouble(-1); + double y = ss.nextDouble(-1); + double w = ss.nextDouble(-1); + double h = ss.nextDouble(-1); + String t = ss.nextString(WINDOW_NORMAL); + + if ((w > 0) && (h > 0)) { + if (isValid(x, y)) { + win.setX(x); + win.setY(y); + } + + if (win instanceof Stage s) { + if (s.isResizable()) { + s.setWidth(w); + s.setHeight(h); + } + + switch (t) { + case WINDOW_FULLSCREEN: + s.setFullScreen(true); + break; + case WINDOW_MAXIMIZED: + s.setMaximized(true); + break; + // TODO iconified? + } + } + } + } + + private static boolean isValid(double x, double y) { + for (Screen s: Screen.getScreens()) { + Rectangle2D r = s.getVisualBounds(); + if (r.contains(x, y)) { + return true; + } + } + return false; + } + + private static String computeName(Node n) { + WindowMonitor m = WindowMonitor.getFor(n); + if (m == null) { + return null; + } + + StringBuilder sb = new StringBuilder(); + if (collectNames(sb, n)) { + return null; + } + + String id = m.getID(); + return id + sb; + } + + // returns true if Node should be ignored + private static boolean collectNames(StringBuilder sb, Node n) { + if (n instanceof MenuBar) { + return true; + } else if (n instanceof Shape) { + return true; + } else if (n instanceof ImageView) { + return true; + } + + Parent p = n.getParent(); + if (p != null) { + if (collectNames(sb, p)) { + return true; + } + } + + String name = getNodeName(n); + if (name == null) { + return true; + } + + sb.append('.'); + sb.append(name); + return false; + } + + private static String getNodeName(Node n) { + if (n != null) { + String name = getName(n); + if (name != null) { + return name; + } + + if (n instanceof Pane) { + if (n instanceof AnchorPane) { + return "AnchorPane"; + } else if (n instanceof BorderPane) { + return "BorderPane"; + } else if (n instanceof DialogPane) { + return "DialogPane"; + } else if (n instanceof FlowPane) { + return "FlowPane"; + } else if (n instanceof GridPane) { + return "GridPane"; + } else if (n instanceof HBox) { + return "HBox"; + } else if (n instanceof StackPane) { + return "StackPane"; + } else if (n instanceof TilePane) { + return "TilePane"; + } else if (n instanceof VBox) { + return "VBox"; + } else { + return "Pane"; + } + } else if (n instanceof Group) { + return "Group"; + } else if (n instanceof Region) { + return "Region"; + } + } + return null; + } + + public static void storeNode(Node n) { + if (n instanceof ListView lv) { + storeListView(lv); + return; + } else if (n instanceof ComboBox cb) { + storeComboBox(cb); + return; + } else if (n instanceof CheckBox cb) { + storeCheckBox(cb); + return; + } else if (n instanceof SplitPane sp) { + storeSplitPane(sp); + return; + } else if (n instanceof ScrollPane sp) { + storeNode(sp.getContent()); + return; + } + + if (n instanceof Parent p) { + for (Node ch: p.getChildrenUnmodifiable()) { + storeNode(ch); + } + } + } + + public static void restoreNode(Node n) { + if (checkNoScene(n)) { + return; + } + + if (n instanceof ListView lv) { + restoreListView(lv); + } else if (n instanceof ComboBox cb) { + restoreComboBox(cb); + } else if (n instanceof CheckBox cb) { + restoreCheckBox(cb); + } else if (n instanceof SplitPane sp) { + restoreSplitPane(sp); + } else if (n instanceof ScrollPane sp) { + restoreNode(sp.getContent()); + } + + if (n instanceof Parent p) { + for (Node ch: p.getChildrenUnmodifiable()) { + restoreNode(ch); + } + } + } + + private static void storeSplitPane(SplitPane sp) { + double[] div = sp.getDividerPositions(); + SStream ss = SStream.writer(); + ss.add(div.length); + for (int i = 0; i < div.length; i++) { + ss.add(div[i]); + } + String name = computeName(sp); + FxSettings.setStream(PREFIX + name, ss); + + for (Node ch: sp.getItems()) { + storeNode(ch); + } + } + + private static void restoreSplitPane(SplitPane sp) { + for (Node ch: sp.getItems()) { + restoreNode(ch); + } + + /** FIX getting smaller and smaller + String name = getName(m, sp); + SStream ss = FxSettings.getStream(PREFIX + name); + if (ss != null) { + int ct = ss.nextInt(-1); + if (ct > 0) { + for (int i = 0; i < ct; i++) { + double div = ss.nextDouble(-1); + if (div < 0) { + break; + } + sp.setDividerPosition(i, div); + } + } + } + */ + } + + private static void storeComboBox(ComboBox n) { + if (n.getSelectionModel() == null) { + return; + } + + int ix = n.getSelectionModel().getSelectedIndex(); + if (ix < 0) { + return; + } + + String name = computeName(n); + if (name == null) { + return; + } + + FxSettings.setInt(PREFIX + name, ix); + } + + // TODO perhaps operate with selection model instead + private static void restoreComboBox(ComboBox n) { + if (n.getSelectionModel() == null) { + return; + } + + if (checkNoScene(n)) { + return; + } + + String name = computeName(n); + if (name == null) { + return; + } + + int ix = FxSettings.getInt(PREFIX + name, -1); + if (ix < 0) { + return; + } else if (ix >= n.getItems().size()) { + return; + } + + n.getSelectionModel().select(ix); + } + + private static boolean checkNoScene(Node node) { + if (node == null) { + return true; + } else if (node.getScene() == null) { + // delay restore until node becomes a part of the scene + node.sceneProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue src, Scene old, Scene scene) { + if (scene != null) { + Window w = scene.getWindow(); + if (w != null) { + node.sceneProperty().removeListener(this); + restoreNode(node); + } + } + } + }); + return true; + } + return false; + } + + private static void storeListView(ListView n) { + if (n.getSelectionModel() == null) { + return; + } + + int ix = n.getSelectionModel().getSelectedIndex(); + if (ix < 0) { + return; + } + + String name = computeName(n); + if (name == null) { + return; + } + + FxSettings.setInt(PREFIX + name, ix); + } + + private static void restoreListView(ListView n) { + if (n.getSelectionModel() == null) { + return; + } + + if (checkNoScene(n)) { + return; + } + + String name = computeName(n); + if (name == null) { + return; + } + + int ix = FxSettings.getInt(PREFIX + name, -1); + if (ix < 0) { + return; + } else if (ix >= n.getItems().size()) { + return; + } + + n.getSelectionModel().select(ix); + } + + private static void storeCheckBox(CheckBox n) { + String name = computeName(n); + if (name == null) { + return; + } + + boolean sel = n.isSelected(); + FxSettings.setBoolean(PREFIX + name, sel); + } + + private static void restoreCheckBox(CheckBox n) { + if (checkNoScene(n)) { + return; + } + + String name = computeName(n); + if (name == null) { + return; + } + + Boolean sel = FxSettings.getBoolean(PREFIX + name); + if (sel == null) { + return; + } + + n.setSelected(sel); + } + + /** sets the name for the purposes of storing user preferences */ + public static void setName(Node n, String name) { + n.getProperties().put(NAME_PROP, name); + } + + /** sets the name for the purposes of storing user preferences */ + public static void setName(Window w, String name) { + w.getProperties().put(NAME_PROP, name); + } + + /** + * Returns the name for the purposes of storing user preferences, + * set previously by {@link #setName(Node, String)}, + * or null. + */ + public static String getName(Node n) { + if (n != null) { + Object x = n.getProperties().get(NAME_PROP); + if (x instanceof String s) { + return s; + } + } + return null; + } + + /** + * Returns the name for the purposes of storing user preferences, + * set previously by {@link #setName(Window, String)}, + * or null. + */ + public static String getName(Window w) { + if (w != null) { + Object x = w.getProperties().get(NAME_PROP); + if (x instanceof String s) { + return s; + } + } + return null; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/ISettingsProvider.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/ISettingsProvider.java new file mode 100644 index 00000000000..e0cea254888 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/ISettingsProvider.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxDock +package com.oracle.demo.rich.settings; + +import java.io.IOException; + +/** + * Defines the interface for storing and loading of settings. + */ +public interface ISettingsProvider { + /** + * Loads settings from persistent storage, if needed. + * @throws IOException + */ + public void load() throws IOException; + + /** + * Saves the settings to persistent media, if needed. + * @throws IOException + */ + public void save() throws IOException; + + /** + * Sets a key-value pair. + */ + public void set(String key, String value); + + /** + * Sets a key-value pair where value is a SStream. + */ + public void set(String key, SStream s); + + /** + * Retrieves a String value for the specific key + */ + public String get(String key); + + /** + * Retrieves a SStream value for the specific key + */ + public SStream getSStream(String key); +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/SStream.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/SStream.java new file mode 100644 index 00000000000..44178b7df61 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/SStream.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.settings; + +import java.util.ArrayList; + +/** + * Represents a string property as a stream of objects. + */ +public abstract class SStream { + + public abstract Object[] toArray(); + + private SStream() { + } + + public static SStream writer() { + return new SStream() { + private ArrayList items = new ArrayList<>(); + + @Override + protected void addValue(Object x) { + items.add(x); + } + + @Override + public Object[] toArray() { + return items.toArray(); + } + }; + } + + public static SStream reader(Object[] items) { + return new SStream() { + int index; + + @Override + protected Object nextValue() { + if (index >= items.length) { + return null; + } + return items[index++]; + } + + @Override + public Object[] toArray() { + return items; + } + }; + } + + public void add(int x) { + addValue(x); + } + + public void add(double x) { + addValue(x); + } + + public void add(String x) { + addValue(x); + } + + protected void addValue(Object x) { + throw new UnsupportedOperationException(); + } + + protected Object nextValue() { + throw new UnsupportedOperationException(); + } + + public final String nextString(String defaultValue) { + Object v = nextValue(); + if (v instanceof String s) { + return s; + } + return defaultValue; + } + + public final double nextDouble(double defaultValue) { + Object v = nextValue(); + if (v instanceof String s) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + // ignore + } + } else if (v instanceof Double d) { + return d; + } + return defaultValue; + } + + public final int nextInt(int defaultValue) { + Object v = nextValue(); + if (v instanceof String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + // ignore + } + } else if (v instanceof Integer d) { + return d; + } + return defaultValue; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(64); + sb.append("["); + boolean sep = false; + for (Object x: toArray()) { + if (sep) { + sb.append(","); + } else { + sep = true; + } + sb.append(x); + } + sb.append("]"); + return sb.toString(); + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/WindowMonitor.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/WindowMonitor.java new file mode 100644 index 00000000000..7189f271673 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/settings/WindowMonitor.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/FxDock +package com.oracle.demo.rich.settings; + +import java.util.HashSet; +import java.util.WeakHashMap; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.stage.Stage; +import javafx.stage.Window; + +/** + * Stage does not keep track of its normal bounds when minimized, maximized, or switched to full screen. + */ +class WindowMonitor { + private final String id; + private double x; + private double y; + private double width; + private double height; + private double x2; + private double y2; + private double w2; + private double h2; + private static final WeakHashMap monitors = new WeakHashMap<>(4); + + public WindowMonitor(Window w, String id) { + this.id = id; + + x = w.getX(); + y = w.getY(); + width = w.getWidth(); + height = w.getHeight(); + + w.xProperty().addListener((p) -> updateX(w)); + w.yProperty().addListener((p) -> updateY(w)); + w.widthProperty().addListener((p) -> updateWidth(w)); + w.heightProperty().addListener((p) -> updateHeight(w)); + + if (w instanceof Stage s) { + s.iconifiedProperty().addListener((p) -> updateIconified(s)); + s.maximizedProperty().addListener((p) -> updateMaximized(s)); + s.fullScreenProperty().addListener((p) -> updateFullScreen(s)); + } + } + + public String getID() { + return id; + } + + public double getX() { + return x; + } + + public double getY() { + return y; + } + + public double getWidth() { + return width; + } + + public double getHeight() { + return height; + } + + private void updateX(Window w) { + x2 = x; + x = w.getX(); + } + + private void updateY(Window w) { + y2 = y; + y = w.getY(); + } + + private void updateWidth(Window w) { + w2 = width; + width = w.getWidth(); + } + + private void updateHeight(Window w) { + h2 = height; + height = w.getHeight(); + } + + private void updateIconified(Stage s) { + if (s.isIconified()) { + x = x2; + y = y2; + } + } + + private void updateMaximized(Stage s) { + if (s.isMaximized()) { + x = x2; + y = y2; + } + } + + private void updateFullScreen(Stage s) { + if (s.isFullScreen()) { + x = x2; + y = y2; + width = w2; + height = h2; + } + } + + public static WindowMonitor getFor(Window w) { + if (w == null) { + return null; + } + WindowMonitor m = monitors.get(w); + if (m == null) { + String id = createID(w); + if (id == null) { + return null; + } + m = new WindowMonitor(w, id); + monitors.put(w, m); + } + return m; + } + + public static WindowMonitor getFor(Node n) { + Window w = windowFor(n); + if (w != null) { + return getFor(w); + } + return null; + } + + private static Window windowFor(Node n) { + Scene sc = n.getScene(); + if (sc != null) { + Window w = sc.getWindow(); + if (w != null) { + return w; + } + } + return null; + } + + private static String createID(Window win) { + String prefix = FxSettingsSchema.getName(win) + "."; + HashSet ids = new HashSet<>(); + for (Window w: Window.getWindows()) { + if (w == win) { + continue; + } + WindowMonitor m = monitors.get(w); + if (m == null) { + return null; + } + String id = m.getID(); + if (id.startsWith(prefix)) { + ids.add(id); + } + } + + for (int i = 0; i < 100_000; i++) { + String id = prefix + i; + if (!ids.contains(id)) { + return id; + } + } + + // safeguard measure + throw new Error("cannot create id: too many windows?"); + } + + public static boolean remove(Window w) { + monitors.remove(w); + return monitors.size() == 0; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/util/FX.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/util/FX.java new file mode 100644 index 00000000000..0186dd13739 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/util/FX.java @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package com.oracle.demo.rich.util; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.css.PseudoClass; +import javafx.event.EventHandler; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Control; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.ToolBar; +import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyCombination; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.stage.Stage; +import javafx.stage.Window; +import com.oracle.demo.rich.settings.FxSettingsSchema; + +/** + * Shortcuts and convenience methods that perhaps could be added to JavaFX. + */ +public class FX { + public static Menu menu(MenuBar b, String text) { + Menu m = new Menu(text); + applyMnemonic(m); + b.getMenus().add(m); + return m; + } + + public static Menu menu(ContextMenu b, String text) { + Menu m = new Menu(text); + applyMnemonic(m); + b.getItems().add(m); + return m; + } + + public static MenuItem item(MenuBar b, String text, Runnable action) { + MenuItem mi = new MenuItem(text); + applyMnemonic(mi); + mi.setOnAction((ev) -> action.run()); + lastMenu(b).getItems().add(mi); + return mi; + } + + public static MenuItem item(MenuBar b, MenuItem mi) { + applyMnemonic(mi); + lastMenu(b).getItems().add(mi); + return mi; + } + + public static MenuItem item(MenuBar b, String text) { + MenuItem mi = new MenuItem(text); + mi.setDisable(true); + applyMnemonic(mi); + lastMenu(b).getItems().add(mi); + return mi; + } + + public static MenuItem item(MenuBar b, String text, FxAction a) { + MenuItem mi = new MenuItem(text); + applyMnemonic(mi); + lastMenu(b).getItems().add(mi); + a.attach(mi); + return mi; + } + + public static CheckMenuItem checkItem(MenuBar b, String text, FxAction a) { + CheckMenuItem mi = new CheckMenuItem(text); + applyMnemonic(mi); + lastMenu(b).getItems().add(mi); + a.attach(mi); + return mi; + } + + public static MenuItem item(ContextMenu cm, String text, FxAction a) { + MenuItem mi = new MenuItem(text); + applyMnemonic(mi); + cm.getItems().add(mi); + a.attach(mi); + return mi; + } + + public static MenuItem item(ContextMenu cm, String text) { + MenuItem mi = new MenuItem(text); + mi.setDisable(true); + applyMnemonic(mi); + cm.getItems().add(mi); + return mi; + } + + public static MenuItem item(Menu b, String text) { + MenuItem mi = new MenuItem(text); + mi.setDisable(true); + applyMnemonic(mi); + b.getItems().add(mi); + return mi; + } + + public static MenuItem item(Menu b, String text, Runnable r) { + MenuItem mi = new MenuItem(text); + mi.setOnAction((ev) -> r.run()); + applyMnemonic(mi); + b.getItems().add(mi); + return mi; + } + + public static Menu submenu(MenuBar b, String text) { + Menu m = new Menu(text); + applyMnemonic(m); + lastMenu(b).getItems().add(m); + return m; + } + + private static void applyMnemonic(MenuItem m) { + String text = m.getText(); + if (text != null) { + if (text.contains("_")) { + m.setMnemonicParsing(true); + } + } + } + + private static Menu lastMenu(MenuBar b) { + List ms = b.getMenus(); + return ms.get(ms.size() - 1); + } + + public static SeparatorMenuItem separator(MenuBar b) { + SeparatorMenuItem s = new SeparatorMenuItem(); + lastMenu(b).getItems().add(s); + return s; + } + + public static SeparatorMenuItem separator(ContextMenu m) { + SeparatorMenuItem s = new SeparatorMenuItem(); + m.getItems().add(s); + return s; + } + + public static RadioMenuItem radio(MenuBar b, String text, KeyCombination accelerator, ToggleGroup g) { + RadioMenuItem mi = new RadioMenuItem(text); + mi.setAccelerator(accelerator); + mi.setToggleGroup(g); + lastMenu(b).getItems().add(mi); + return mi; + } + + public static CheckMenuItem checkItem(ContextMenu c, String name, boolean selected, Consumer client) { + CheckMenuItem m = new CheckMenuItem(name); + m.setSelected(selected); + m.setOnAction((ev) -> { + boolean on = m.isSelected(); + client.accept(on); + }); + c.getItems().add(m); + return m; + } + + public static CheckMenuItem checkItem(Menu c, String name, boolean selected, Consumer client) { + CheckMenuItem m = new CheckMenuItem(name); + m.setSelected(selected); + m.setOnAction((ev) -> { + boolean on = m.isSelected(); + client.accept(on); + }); + c.getItems().add(m); + return m; + } + + public static ToggleButton toggleButton(ToolBar t, String text, FxAction a) { + ToggleButton b = new ToggleButton(text); + a.attach(b); + t.getItems().add(b); + return b; + } + + public static ToggleButton toggleButton(ToolBar t, String text, String tooltip, FxAction a) { + ToggleButton b = new ToggleButton(text); + b.setTooltip(new Tooltip(tooltip)); + a.attach(b); + t.getItems().add(b); + return b; + } + + public static ToggleButton toggleButton(ToolBar t, String text, String tooltip) { + ToggleButton b = new ToggleButton(text); + b.setTooltip(new Tooltip(tooltip)); + b.setDisable(true); + t.getItems().add(b); + return b; + } + + public static Button button(ToolBar t, String text, String tooltip, FxAction a) { + Button b = new Button(text); + b.setTooltip(new Tooltip(tooltip)); + a.attach(b); + t.getItems().add(b); + return b; + } + + public static Button button(ToolBar t, String text, String tooltip) { + Button b = new Button(text); + b.setTooltip(new Tooltip(tooltip)); + b.setDisable(true); + t.getItems().add(b); + return b; + } + + public static N add(ToolBar t, N child) { + t.getItems().add(child); + return child; + } + + public static void space(ToolBar t) { + Pane p = new Pane(); + p.setPrefSize(10, 10); + t.getItems().add(p); + } + + public static void tooltip(Control c, String text) { + c.setTooltip(new Tooltip(text)); + } + + public static void add(GridPane p, Node n, int col, int row) { + p.getChildren().add(n); + GridPane.setConstraints(n, col, row); + } + + public static void select(ComboBox cb, T value) { + cb.getSelectionModel().select(value); + } + + public static void selectFirst(ComboBox cb) { + cb.getSelectionModel().selectFirst(); + } + + public static T getSelectedItem(ComboBox cb) { + return cb.getSelectionModel().getSelectedItem(); + } + + public static Window getParentWindow(Object nodeOrWindow) { + if (nodeOrWindow == null) { + return null; + } else if (nodeOrWindow instanceof Window w) { + return w; + } else if (nodeOrWindow instanceof Node n) { + Scene s = n.getScene(); + if (s != null) { + return s.getWindow(); + } + return null; + } else { + throw new Error("Node or Window only"); + } + } + + /** cascades the window relative to its owner, if any */ + public static void cascade(Stage w) { + if (w != null) { + Window p = w.getOwner(); + if (p != null) { + double x = p.getX(); + double y = p.getY(); + double off = 20; + w.setX(x + off); + w.setY(y + off); + } + } + } + + /** adds a name property to the Node for the purposes of storing the preferences */ + public static void name(Node n, String name) { + FxSettingsSchema.setName(n, name); + } + + /** adds a name property to the Window for the purposes of storing the preferences */ + public static void name(Window w, String name) { + FxSettingsSchema.setName(w, name); + } + + /** + * attach a popup menu to a node. + * WARNING: sometimes, as the case is with TableView/FxTable header, + * the requested node gets created by the skin at some later time. + * In this case, additional dance must be performed, see for example + * FxTable.setHeaderPopupMenu() + */ + // https://github.com/andy-goryachev/MP3Player/blob/8b0ff12460e19850b783b961f214eacf5e1cdaf8/src/goryachev/fx/FX.java#L1251 + public static void setPopupMenu(Node owner, Supplier generator) { + if (owner == null) { + throw new NullPointerException("cannot attach popup menu to null"); + } + + owner.setOnContextMenuRequested((ev) -> { + if (generator != null) { + ContextMenu m = generator.get(); + if (m != null) { + if (m.getItems().size() > 0) { + Platform.runLater(() -> { + // javafx does not dismiss the popup when the user + // clicks on the owner node + EventHandler li = new EventHandler() { + @Override + public void handle(MouseEvent event) { + m.hide(); + owner.removeEventFilter(MouseEvent.MOUSE_PRESSED, this); + event.consume(); + } + }; + + owner.addEventFilter(MouseEvent.MOUSE_PRESSED, li); + m.show(owner, ev.getScreenX(), ev.getScreenY()); + }); + ev.consume(); + } + } + } + ev.consume(); + }); + } + + /** + * Sets opacity (alpha) value. + * @param c the initial color + * @param opacity the opacity value + * @return the new Color with specified opacity + */ + public static Color alpha(Color c, double opacity) { + double r = c.getRed(); + double g = c.getGreen(); + double b = c.getBlue(); + return new Color(r, g, b, opacity); + } + + /** + * Returns the node of type {@code type}, which is either the ancestor or the specified node, + * or the specified node itself. + * @param the class of Node + * @param type the class of Node + * @param n the node to look at + * @return the ancestor of type N, or null + */ + public static N findParentOf(Class type, Node n) { + for (;;) { + if (n == null) { + return null; + } else if (type.isAssignableFrom(n.getClass())) { + return (N)n; + } + n = n.getParent(); + } + } + + /** + * Adds the specified style name to the Node's style list. + * @param n the node + * @param name the style name to add + */ + public static void style(Node n, String name) { + if (n != null) { + n.getStyleClass().add(name); + } + } + + /** + * Adds or removes the specified style name to the Node's style list. + * @param n the node + * @param name the style name to add + * @param add whether to add or remove the style + */ + public static void style(Node n, String name, boolean add) { + if (n != null) { + if (add) { + n.getStyleClass().add(name); + } else { + n.getStyleClass().remove(name); + } + } + } + + /** + * Adds or removes the specified pseudo class to the Node's style list. + * @param n the node + * @param name the style name to add + * @param on whether to add or remove the pseudo class + */ + public static void style(Node n, PseudoClass name, boolean on) { + if (n != null) { + n.pseudoClassStateChanged(name, on); + } + } + + public static Button button(String text, Runnable r) { + Button b = new Button(text); + if (r == null) { + b.setDisable(true); + } else { + b.setOnAction((ev) -> r.run()); + } + return b; + } +} diff --git a/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/util/FxAction.java b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/util/FxAction.java new file mode 100644 index 00000000000..154dc440234 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/com/oracle/demo/rich/util/FxAction.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +// This code borrows heavily from the following project, with permission from the author: +// https://github.com/andy-goryachev/AppFramework +package com.oracle.demo.rich.util; + +import java.util.function.Consumer; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.RadioMenuItem; +import javafx.scene.control.ToggleButton; + +/** + * An AbstractAction equivalent for FX, using method references. + *

+ * Usage: + *

+ *    public final FxAction backAction = new FxAction(this::actionBack);
+ * 
+ */ +public class FxAction implements EventHandler { + private final SimpleBooleanProperty selectedProperty = new SimpleBooleanProperty(); + private final SimpleBooleanProperty disabledProperty = new SimpleBooleanProperty(); + private Runnable onAction; + private Consumer onSelected; + + public FxAction(Runnable onAction, Consumer onSelected, boolean enabled) { + this.onAction = onAction; + this.onSelected = onSelected; + setEnabled(enabled); + + if (onSelected != null) { + selectedProperty.addListener((src, prev, cur) -> fireSelected(cur)); + } + } + + public FxAction(Runnable onAction, Consumer onSelected) { + this(onAction, onSelected, true); + } + + public FxAction(Runnable onAction, boolean enabled) { + this(onAction, null, enabled); + } + + public FxAction(Runnable onAction) { + this.onAction = onAction; + } + + public FxAction() { + } + + public void setOnAction(Runnable r) { + onAction = r; + } + + protected final void invokeAction() { + if (onAction != null) { + try { + onAction.run(); + } catch (Throwable e) { + //log.error(e); + e.printStackTrace(); + } + } + } + + public void attach(ButtonBase b) { + b.setOnAction(this); + b.disableProperty().bind(disabledProperty()); + + if (b instanceof ToggleButton) { + ((ToggleButton)b).selectedProperty().bindBidirectional(selectedProperty()); + } + } + + public void attach(MenuItem m) { + m.setOnAction(this); + m.disableProperty().bind(disabledProperty()); + + if (m instanceof CheckMenuItem) { + ((CheckMenuItem)m).selectedProperty().bindBidirectional(selectedProperty()); + } else if (m instanceof RadioMenuItem) { + ((RadioMenuItem)m).selectedProperty().bindBidirectional(selectedProperty()); + } + } + + public final BooleanProperty selectedProperty() { + return selectedProperty; + } + + public final boolean isSelected() { + return selectedProperty.get(); + } + + public final void setSelected(boolean on, boolean fire) { + if (selectedProperty.get() != on) { + selectedProperty.set(on); + if (fire) { + fire(); + } + } + } + + public final BooleanProperty disabledProperty() { + return disabledProperty; + } + + public final boolean isDisabled() { + return disabledProperty.get(); + } + + public final void setDisabled(boolean on) { + disabledProperty.set(on); + } + + public final boolean isEnabled() { + return !isDisabled(); + } + + public final void setEnabled(boolean on) { + disabledProperty.set(!on); + } + + public final void enable() { + setEnabled(true); + } + + public final void disable() { + setEnabled(false); + } + + /** fire onAction handler only if this action is enabled */ + public void fire() { + if (isEnabled()) { + handle(null); + } + } + + /** execute an action regardless of whether its enabled or not */ + public void execute() { + try { + invokeAction(); + } catch (Throwable e) { + //log.error(e); + e.printStackTrace(); + } + } + + protected void fireSelected(boolean on) { + try { + onSelected.accept(on); + } catch (Throwable e) { + //log.error(e); + e.printStackTrace(); + } + } + + /** override to obtain the ActionEvent */ + @Override + public void handle(ActionEvent ev) { + if (isEnabled()) { + if (ev != null) { + if (ev.getSource() instanceof Menu) { + if (ev.getSource() != ev.getTarget()) { + // selection of a cascading child menu triggers action event for the parent + // for some unknown reason. ignore this. + return; + } + } + + ev.consume(); + } + + execute(); + + // close popup menu, if applicable + if (ev != null) { + Object src = ev.getSource(); + if (src instanceof Menu) { + ContextMenu p = ((Menu)src).getParentPopup(); + if (p != null) { + p.hide(); + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/manual/RichTextAreaDemo/src/module-info.java b/tests/manual/RichTextAreaDemo/src/module-info.java new file mode 100644 index 00000000000..d7ba3d83adb --- /dev/null +++ b/tests/manual/RichTextAreaDemo/src/module-info.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/** + * RichTextArea control demo. + * + *
Incubating Feature. + * Will be removed in a future release. + * + * @moduleGraph + */ + +module RichTextAreaDemo { + exports com.oracle.demo.rich.codearea; + exports com.oracle.demo.rich.editor; + exports com.oracle.demo.rich.notebook; + exports com.oracle.demo.rich.rta; + + requires javafx.base; + requires javafx.controls; + requires javafx.graphics; + requires jfx.incubator.input; + requires jfx.incubator.richtext; +} diff --git a/tests/manual/RichTextAreaDemo/test/test/com/oracle/demo/rich/codearea/TestJavaSyntaxDecorator.java b/tests/manual/RichTextAreaDemo/test/test/com/oracle/demo/rich/codearea/TestJavaSyntaxDecorator.java new file mode 100644 index 00000000000..2c21ac3a256 --- /dev/null +++ b/tests/manual/RichTextAreaDemo/test/test/com/oracle/demo/rich/codearea/TestJavaSyntaxDecorator.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package test.com.oracle.demo.rich.codearea; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import com.oracle.demo.rich.codearea.JavaSyntaxAnalyzer; + +/** + * Tests JavaSyntaxDecorator. + */ +public class TestJavaSyntaxDecorator { + private static final JavaSyntaxAnalyzer.Type H = JavaSyntaxAnalyzer.Type.CHARACTER; + private static final JavaSyntaxAnalyzer.Type C = JavaSyntaxAnalyzer.Type.COMMENT; + private static final JavaSyntaxAnalyzer.Type K = JavaSyntaxAnalyzer.Type.KEYWORD; + private static final JavaSyntaxAnalyzer.Type N = JavaSyntaxAnalyzer.Type.NUMBER; + private static final JavaSyntaxAnalyzer.Type T = JavaSyntaxAnalyzer.Type.OTHER; + private static final JavaSyntaxAnalyzer.Type S = JavaSyntaxAnalyzer.Type.STRING; + private static final Object NL = new Object(); + + @Test + public void specialCases() { + t(T, "print(x);"); + t(K, "new", T, " StringPropertyBase(", S, "\"\"", T, ") {"); + t(K, "import", T, " javafx.geometry.BoundingBox;"); + t(T, "tempState.point.y = "); + t(T, "FX.checkItem(m, ", S, "\"1\"", T, " ", K, "new", T, " Insets(", N, "1", T, ").equals(t.getContentPadding()), (on) -> {", NL); + t(K, "import", T, " atry.a;"); + } + + private void someExamplesOfValidCode() { + // text block + var s = """ + ---/** */ -- // return ; + """ ; + + // numbers + double x = .1e15; + x = 1.5e2; + x = 1e3f; + x = 1.1f; + x = 5_0.1e2_3; + x = 1_000_000; + x = -1_000e-1; + x = +1_000e+1; + x = 1__1e-1_______________________________1; + x = 0b10100001010001011010000101000101; + x = 0b1010_0001_0100_0_1011_________01000010100010___1; + x = 0x0_000__00; + } + + @Test + public void tests() { + // hex + t(N, "0x0123456789abcdefL"); + t(N, "0x00", NL, N, "0x0123456789abcdefL"); + t(N, "0xeFeF", NL, N, "0x0123__4567__89ab_cdefL"); + + // binary + t(N, "0b00000"); + t(N, "0b1010101010L"); + + // doubles + t(N, "1___2e-3___6"); + t(N, ".15e2"); + t(N, "3.141592"); + t(N, ".12345"); + t(N, "1.5e2"); + t(N, "1.5e2_2"); + t(N, "1.5E-2"); + t(N, "1_2.5E-2"); + t(N, ".57E22"); + t(N, ".75E-5"); + t(N, "1D"); + t(N, "1___2e-3___6d"); + t(N, ".15e2d"); + t(N, "3.141592d"); + t(N, ".12345d"); + t(N, "1.5e2d"); + t(N, "1.5e2_2d"); + t(N, "1.5E-2d"); + t(N, "1_2.5E-2d"); + t(N, ".57E22d"); + t(N, ".75E-5d"); + t(N, "1D", NL, N, "1d", NL, N, "1.1D", NL, N, "1.1d", NL, N, "1.2e-3d", NL, N, "1.2e-3D", NL, N, "1.2E+3d"); + + // floats + t(N, "1f"); + t(N, "1___2e-3___6f"); + t(N, ".15e2f"); + t(N, "3.141592f"); + t(N, ".12345f"); + t(N, "1.5e2f"); + t(N, "1.5e2_2f"); + t(N, "1.5E-2f"); + t(N, "1_2.5E-2f"); + t(N, ".57E22f"); + t(N, ".75E-5f"); + t(N, "1F", NL, N, "1f", NL, N, "1.1F", NL, N, "1.1f", NL, N, "1.2e-3f", NL, N, "1.2e-3F", NL, N, "1.2E+3f"); + + // longs + t(N, "1L", NL, N, "1l", NL); + t(N, "2_2L", NL, N, "2_2l", NL); + t(N, "2____2L", NL, N, "2___2l", NL); + t(T, "-", N, "99999L", NL); + t(T, "5.L"); + + // integers + t(N, "1"); + t(N, "1_0"); + t(N, "1_000_000_000"); + t(N, "1______000___000_____000"); + // negative scenarios with integers + t(T, "_1"); + t(T, "1_"); + t(T, "-", N, "9999"); + + // text blocks + t(T, "String s =", S, "\"\"\" ", NL, S, " yo /* // */ */ \"\" \" ", NL, S, "a \"\"\" ", T, ";"); + + // strings + t(T, " ", S, "\"\\\"/*\\\"\"", NL); + t(S, "\"\\\"\\\"\\\"\"", T, " {", NL); + t(S, "\"abc\"", NL, T, "s = ", S, "\"\""); + + // comments + t(T, " ", C, "/* yo", NL, C, "yo yo", NL, C, " */", T, " "); + t(T, " ", C, "// yo yo", NL, K, "int", T, " c;"); + t(C, "/* // yo", NL, C, "// */", T, " "); + + // chars + t(H, "'\\b'"); + t(H, "'\\b'", NL); + t(H, "'\\u0000'", NL, H, "'\\uFf9a'", NL); + t(H, "'a'", NL, H, "'\\b'", NL, H, "'\\f'", NL, H, "'\\n'", NL, H, "'\\r'", NL); + t(H, "'\\''", NL, H, "'\\\"'", NL, H, "'\\\\'", NL); + + // keywords + t(K, "package", T, " java.com;", NL); + t(K, "import", T, " java.util.ArrayList;", NL); + t(K, "import", T, " java.util.ArrayList;", NL, K, "import", T, " java.util.ArrayList;", NL); + t(K, "import", T, " com.oracle.demo"); + + // misc + t(K, "if", T, "(", S, "\"/*\"", T, " == null) {", NL); + t(C, "// test", NL, T, "--", NL); + t(T, "S_0,"); + } + + private void t(Object... items) { + StringBuilder sb = new StringBuilder(); + ArrayList expected = new ArrayList<>(); + JavaSyntaxAnalyzer.Line line = null; + + // builds the input string and the expected result array + for (int i = 0; i < items.length; ) { + Object x = items[i++]; + if (x == NL) { + sb.append("\n"); + if (line == null) { + line = new JavaSyntaxAnalyzer.Line(); + } + expected.add(line); + line = null; + } else { + JavaSyntaxAnalyzer.Type t = (JavaSyntaxAnalyzer.Type)x; + String text = (String)items[i++]; + if (line == null) { + line = new JavaSyntaxAnalyzer.Line(); + } + line.addSegment(t, text); + sb.append(text); + } + } + + if (line != null) { + expected.add(line); + } + + String input = sb.toString(); + List res = new JavaSyntaxAnalyzer(input).analyze(); + Assertions.assertArrayEquals(expected.toArray(), res.toArray()); + } +}