diff --git a/.github/workflows/guibuilder.yml b/.github/workflows/guibuilder.yml new file mode 100644 index 00000000000..66ef7f4c647 --- /dev/null +++ b/.github/workflows/guibuilder.yml @@ -0,0 +1,114 @@ +name: GUI Builder + +# The standalone GUI Builder lives outside the Maven reactor (scripts/guibuilder) +# and PR CI ignores scripts/**, so nothing else compiles it. It depends on core +# APIs (CodeEditor protected regions, LayeredLayout baseline units) that can be +# changed in the same PR, which is exactly how the editor silently stops +# compiling against a freshly built core. + +on: + workflow_dispatch: + pull_request: + branches: + - master + paths: + - '.github/workflows/guibuilder.yml' + - 'scripts/guibuilder/**' + - '!scripts/guibuilder/**/*.md' + # The editor is an ordinary Codename One app: it renders the design canvas with + # Tabs, TableLayout, SplitPane and the rest of the core UI, compiles the project + # stylesheet with the CSS compiler, and runs on the JavaSE port. Listing only the + # few classes it obviously touches let a core change break it with this job skipped. + - 'CodenameOne/src/**' + - 'Ports/JavaSE/src/**' + - 'maven/css-compiler/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + push: + branches: + - master + paths: + - '.github/workflows/guibuilder.yml' + - 'scripts/guibuilder/**' + - '!scripts/guibuilder/**/*.md' + # The editor is an ordinary Codename One app: it renders the design canvas with + # Tabs, TableLayout, SplitPane and the rest of the core UI, compiles the project + # stylesheet with the CSS compiler, and runs on the JavaSE port. Listing only the + # few classes it obviously touches let a core change break it with this job skipped. + - 'CodenameOne/src/**' + - 'Ports/JavaSE/src/**' + - 'maven/css-compiler/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + guibuilder: + runs-on: ubuntu-latest + timeout-minutes: 60 + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v6 + + - name: Cache Maven dependencies + uses: actions/cache@v5 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2 + + - name: Prepare Codename One binaries + # The JavaSE port needs jfxrt.jar from cn1-binaries; without it the + # build fails with a large, misleading set of missing javafx.* errors. + run: | + set -euo pipefail + rm -rf maven/target/cn1-binaries + mkdir -p maven/target + cp -r /opt/cn1-binaries maven/target/cn1-binaries + + - name: Install Codename One artifacts (JDK 8) + # JAVA_HOME_8 / JAVA_HOME_17 are container image environment variables, + # so they are expanded by the shell rather than the env context. + run: | + set -euo pipefail + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME}/bin:${PATH}" + cd maven + mvn -B install -Plocal-dev-javase -DskipTests \ + -Darchetype.test.skip=true -Dmaven.javadoc.skip=true \ + -Dcn1.binaries="${GITHUB_WORKSPACE}/maven/target/cn1-binaries" + + # Built and tested on JDK 8 so that the editor stays loadable on every JDK a Codename One + # project may be built with. A newer JDK here would let a post-8 API through unnoticed. + - name: Run standalone GUI Builder tests (JDK 8) + run: | + set -euo pipefail + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME}/bin:${PATH}" + cd scripts/guibuilder + xvfb-run -a mvn -B -pl javase -am clean test -Dcodename1.platform=javase + + - name: Package the executable GUI Builder (JDK 8) + run: | + set -euo pipefail + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME}/bin:${PATH}" + cd scripts/guibuilder + xvfb-run -a mvn -B -pl javase -am -Pexecutable-jar package \ + -Dcodename1.platform=javase -Dmaven.test.skip=true + ls javase/target/codenameone-guibuilder-*.jar + + - name: Upload GUI Builder test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: guibuilder-test-reports + path: scripts/guibuilder/javase/target/surefire-reports + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release-on-maven-central.yml b/.github/workflows/release-on-maven-central.yml index 5d23a530e06..aaa3360b592 100644 --- a/.github/workflows/release-on-maven-central.yml +++ b/.github/workflows/release-on-maven-central.yml @@ -15,9 +15,9 @@ concurrency: jobs: build: runs-on: ubuntu-latest - # The four Central confirmation polls each cover the documented 30-minute + # The five Central confirmation polls each cover the documented 30-minute # propagation window, and each fires only when its own deploy reported failure, - # so the worst case is ~2h of polling on top of the build. Bounded explicitly + # so the worst case is ~2.5h of polling on top of the build. Bounded explicitly # rather than inheriting the 6h default, so a wedged release fails in a shift # rather than overnight. timeout-minutes: 240 @@ -62,7 +62,7 @@ jobs: export GPG_TTY=$(tty) # Once dual publish is off, R2 is the repository and Central must not receive # new immutable releases. skipPublishing still stages, so the R2 upload that - # follows is unaffected -- same treatment as the three editor deploys. + # follows is unaffected -- same treatment as the four editor deploys. skip_publishing=true if [ "${{ vars.CN1_DUAL_PUBLISH }}" = "true" ]; then skip_publishing=false @@ -246,7 +246,7 @@ jobs: continue-on-error: true # always(): central-publishing stages before it uploads, so the tree exists # even when the Central deploy above failed -- which is precisely the case - # R2 is here to survive. Metadata is regenerated once, after all three. + # R2 is here to survive. Metadata is regenerated once, after all four. if: always() && steps.deploy_gamebuilder.conclusion != 'skipped' run: | # Each editor is its own reactor, so it stages into its own target dir. @@ -335,7 +335,7 @@ jobs: continue-on-error: true # always(): central-publishing stages before it uploads, so the tree exists # even when the Central deploy above failed -- which is precisely the case - # R2 is here to survive. Metadata is regenerated once, after all three. + # R2 is here to survive. Metadata is regenerated once, after all four. if: always() && steps.deploy_certificatewizard.conclusion != 'skipped' run: | # Each editor is its own reactor, so it stages into its own target dir. @@ -413,7 +413,7 @@ jobs: continue-on-error: true # always(): central-publishing stages before it uploads, so the tree exists # even when the Central deploy above failed -- which is precisely the case - # R2 is here to survive. Metadata is regenerated once, after all three. + # R2 is here to survive. Metadata is regenerated once, after all four. if: always() && steps.deploy_settings.conclusion != 'skipped' run: | # Each editor is its own reactor, so it stages into its own target dir. @@ -447,10 +447,88 @@ jobs: echo "codenameone-settings ${GITHUB_REF_NAME} never appeared on Maven Central within 30 minutes." >&2 exit 1 + # --- GUI Builder editor ------------------------------------------------- + # The cn1:guibuilder goal resolves the standalone Java-17 GUI Builder and + # its runtime dependencies from Maven Central, exactly like cn1:settings. + - name: Deploy GUI Builder editor to Maven Central + id: deploy_guibuilder + continue-on-error: true + if: >- + always() && (steps.deploy.outcome == 'success' || steps.confirm.outcome == 'success' || + steps.r2_core.outcome == 'success') + run: | + export GPG_TTY=$(tty) + # Do not publish this editor to Central unless the core release actually + # reached Central. The editor declares core/plugin at this same version, so + # publishing it against a core that is not there leaves an immutable Central + # release whose dependencies cannot resolve. skipPublishing still stages the + # artifacts, so the R2 upload that follows is unaffected. + skip_publishing=true + if [ "${{ vars.CN1_DUAL_PUBLISH }}" = "true" ] \ + && { [ "${{ steps.deploy.outcome }}" = "success" ] \ + || [ "${{ steps.confirm.outcome }}" = "success" ]; }; then + skip_publishing=false + else + echo "Core release did not reach Central (or dual publish is off):" + echo "staging this editor for R2 only." + fi + cd scripts/guibuilder + xvfb-run -a mvn -Pexecutable-jar -Pguibuilder-central deploy \ + -DskipPublishing=$skip_publishing \ + -Dcodename1.platform=javase \ + -Dgpg.passphrase=$MAVEN_GPG_PASSPHRASE \ + -Dcn1.version=$GITHUB_REF_NAME -Dcn1.plugin.version=$GITHUB_REF_NAME \ + -Dmaven.test.skip=true + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + + - name: Publish GUI Builder editor to R2 + id: r2_guibuilder + # Non-fatal for the same reason as the core upload: one editor's R2 failure + # must not skip the remaining editors' Central deploys. + continue-on-error: true + # always(): central-publishing stages before it uploads, so the tree exists + # even when the Central deploy above failed -- which is precisely the case + # R2 is here to survive. Metadata is regenerated once, after all four. + if: always() && steps.deploy_guibuilder.conclusion != 'skipped' + run: | + # Each editor is its own reactor, so it stages into its own target dir. + bash maven/scripts/r2/publish-staging-to-r2.sh scripts/guibuilder/target/central-staging + url="${R2_BASE_URL}/com/codenameone/codenameone-guibuilder/${GITHUB_REF_NAME}/codenameone-guibuilder-${GITHUB_REF_NAME}.pom?cb=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + code=$(curl -s -o /dev/null -w "%{http_code}" "$url") + [ "$code" = "200" ] || { echo "MISSING on R2: codenameone-guibuilder (HTTP $code)" >&2; exit 1; } + echo "ok: codenameone-guibuilder ${GITHUB_REF_NAME} on R2" + + - name: Confirm GUI Builder editor on Maven Central + id: confirm_guibuilder + # Runs only to second-guess a failed deploy: central-publishing + # reports "Deployment failed while publishing" for bundles it actually accepted, + # and Central releases are immutable so a rerun cannot repair that. + if: >- + always() && vars.CN1_DUAL_PUBLISH == 'true' && + steps.deploy_guibuilder.outcome == 'failure' + continue-on-error: true + run: | + set +e + url="https://repo1.maven.org/maven2/com/codenameone/codenameone-guibuilder/${GITHUB_REF_NAME}/codenameone-guibuilder-${GITHUB_REF_NAME}.jar" + for i in $(seq 1 90); do + code=$(curl -s -o /dev/null -w "%{http_code}" "$url") + if [ "$code" = "200" ]; then + echo "Confirmed codenameone-guibuilder ${GITHUB_REF_NAME} on Maven Central" + exit 0 + fi + echo "[$i/90] Waiting on Maven Central for codenameone-guibuilder (code=$code)" + sleep 20 + done + echo "codenameone-guibuilder ${GITHUB_REF_NAME} never appeared on Maven Central within 30 minutes." >&2 + exit 1 + - name: Mark the R2 release complete id: r2_mark_complete continue-on-error: true - # Only now, once the core reactor and all three editors are up AND the core + # Only now, once the core reactor and all four editors are up AND the core # artifacts have been observed on R2. This is what makes the tag discoverable: # regen refuses to advertise a version without it, on this run and every future # one. r2_core_confirm is what verifies the expected artifacts are actually @@ -465,7 +543,8 @@ jobs: steps.r2_core_confirm.outcome == 'success' && steps.r2_gamebuilder.outcome == 'success' && steps.r2_certificatewizard.outcome == 'success' && - steps.r2_settings.outcome == 'success' + steps.r2_settings.outcome == 'success' && + steps.r2_guibuilder.outcome == 'success' run: bash maven/scripts/r2/mark-release-complete.sh "${GITHUB_REF_NAME}" - name: Regenerate R2 metadata and archetype catalog @@ -517,6 +596,7 @@ jobs: bad "${{ steps.r2_gamebuilder.outcome }}" && fail "Game Builder -> R2" bad "${{ steps.r2_certificatewizard.outcome }}" && fail "Signing Wizard -> R2" bad "${{ steps.r2_settings.outcome }}" && fail "Settings -> R2" + bad "${{ steps.r2_guibuilder.outcome }}" && fail "GUI Builder -> R2" bad "${{ steps.r2_mark_complete.outcome }}" && fail "marking the R2 release complete" bad "${{ steps.r2_metadata.outcome }}" && fail "R2 metadata regeneration" if [ "${{ vars.CN1_DUAL_PUBLISH }}" = "true" ]; then @@ -538,6 +618,10 @@ jobs: && [ "${{ steps.confirm_settings.outcome }}" != "success" ]; then fail "Settings -> Maven Central" fi + if bad "${{ steps.deploy_guibuilder.outcome }}" \ + && [ "${{ steps.confirm_guibuilder.outcome }}" != "success" ]; then + fail "GUI Builder -> Maven Central" + fi fi [ "$status" = "0" ] && echo " all publications succeeded" exit $status diff --git a/CodenameOne/src/com/codename1/components/SplitPane.java b/CodenameOne/src/com/codename1/components/SplitPane.java index ad78355aa0d..0a753a7c080 100644 --- a/CodenameOne/src/com/codename1/components/SplitPane.java +++ b/CodenameOne/src/com/codename1/components/SplitPane.java @@ -29,6 +29,7 @@ import com.codename1.ui.Container; import com.codename1.ui.Display; import com.codename1.ui.FontImage; +import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.Label; @@ -1094,7 +1095,10 @@ protected boolean isStickyDrag() { @Override protected void initComponent() { super.initComponent(); - getComponentForm().setEnableCursors(true); + Form form = getComponentForm(); + if (form != null) { + form.setEnableCursors(true); + } } diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 9f3e98c5cc0..bdd00be030d 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -5428,8 +5428,9 @@ public String editorPeerQuery(PeerComponent peer, String name, String arg) { /// Returns true when this platform can bind a `com.codename1.ui.TextInputClient` to a low level text /// input source (soft keyboard / IME / hardware keyboard) so a component can capture raw text input - /// while rendering the document itself. When false the pure Codename One editors fall back to their - /// `BrowserComponent` backend. The default returns false. + /// while rendering the document itself. When false the pure Codename One editors read the physical + /// keyboard directly through `Component#keyReleased(int)`; there is no browser or HTML backend. + /// The default returns false. public boolean isTextInputSupported() { return false; } diff --git a/CodenameOne/src/com/codename1/ui/CodeEditor.java b/CodenameOne/src/com/codename1/ui/CodeEditor.java index b1b1d9cef8e..92a110c6b0f 100644 --- a/CodenameOne/src/com/codename1/ui/CodeEditor.java +++ b/CodenameOne/src/com/codename1/ui/CodeEditor.java @@ -26,6 +26,9 @@ import com.codename1.ui.editor.CodePureEditor; import com.codename1.ui.editor.PureEditor; import com.codename1.ui.editor.SyntaxHighlighter; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.util.EventDispatcher; import com.codename1.util.SuccessCallback; import java.util.Hashtable; @@ -66,6 +69,7 @@ public class CodeEditor extends AbstractEditorComponent { private boolean showLineNumbers = true; private int tabSize = 4; private CodeCompletionProvider completionProvider; + private final EventDispatcher protectedEditListeners = new EventDispatcher(); /// Creates an empty code editor. public CodeEditor() { @@ -219,6 +223,39 @@ public void insertAtCursor(String text) { command("insertText", text); } + /// Protects all text between matching marker lines from editing. This is intended for generated + /// source previews that contain explicit user-editable regions. Passing null clears protection. + public void setProtectedRegionMarkers(String startMarker, String endMarker) { + command("setProtectedMarkers", startMarker == null || endMarker == null + ? "" : startMarker + "\n" + endMarker); + } + + /// Registers a listener notified when an edit is refused because it falls inside a protected + /// region. Without it a protected region is silent, and one covering most of the document is + /// indistinguishable from an editor that ignores the keyboard. The event source is this editor + /// and the event is fired on the EDT. + /// + /// #### Parameters + /// + /// - `listener`: invoked once per refused edit + public void addProtectedEditListener(ActionListener listener) { + protectedEditListeners.addListener(listener); + } + + /// Removes a listener added by `#addProtectedEditListener(ActionListener)`. + /// + /// #### Parameters + /// + /// - `listener`: the listener to remove + public void removeProtectedEditListener(ActionListener listener) { + protectedEditListeners.removeListener(listener); + } + + /// Moves the caret to a character offset, clamped by the editor backend to the document bounds. + public void setCursorPosition(int offset) { + command("setCursor", String.valueOf(Math.max(0, offset))); + } + /// Retrieves the current caret character offset. The callback is invoked on the EDT. /// /// #### Parameters @@ -303,6 +340,10 @@ void onEditorEvent(String type, String value) { handleCompletionRequest(value); return; } + if ("protectedEdit".equals(type)) { + protectedEditListeners.fireActionEvent(new ActionEvent(this)); + return; + } super.onEditorEvent(type, value); } diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index 179d3b28814..59f963821e6 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -4263,6 +4263,22 @@ public void setHandlesInput(boolean handlesInput) { this.handlesInput = handlesInput; } + /// Whether this component turns key codes into printable characters, as a text editor does. + /// + /// Key codes and character codes share one value space, so a port is free to map a soft key + /// onto a value that is also a printable character: the desktop port maps the left soft key to + /// `VK_F1`, which is 112, the code of a lowercase `p`. A form serves a component that answers + /// true here before the menu bar, or that character could never be typed into it. This is + /// deliberately narrower than `#handlesInput()`, which components such as lists and editable + /// sliders also set for focus traversal while still expecting their soft keys to work. + /// + /// #### Returns + /// + /// true if key codes reaching this component are text rather than commands + protected boolean consumesRawTextInput() { + return false; + } + /// Returns true if the component has focus /// /// #### Returns diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index 1d730003337..27a85c75e93 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -3159,7 +3159,7 @@ public TabIterator getTabIterator(Component start) { @Override public void keyPressed(int keyCode) { int game = Display.getInstance().getGameAction(keyCode); - if (menuBar.handlesKeycode(keyCode)) { + if (menuBar.handlesKeycode(keyCode) && !focusedHandlesInput(keyCode)) { menuBar.keyPressed(keyCode); return; } @@ -3224,11 +3224,47 @@ public void setMinimizeOnBack(boolean minimizeOnBack) { menuBar.setMinimizeOnBack(minimizeOnBack); } + /// True when the focused component turns key codes into text, so soft key mapping must not + /// intercept keys ahead of it. + /// + /// A port is free to map a soft key onto any key code, and the desktop port uses the function + /// keys: `VK_F1` is 112, which is also the character code of a lowercase `p`. Key codes and + /// character codes share one value space here, so a text component would silently never receive + /// that character -- one letter of the alphabet simply stopped working. + /// + /// `Component#handlesInput()` is too broad a test for this: lists, editable sliders and map + /// components set it for focus traversal while still expecting the back and menu commands to + /// reach the menu bar, so only components that declare themselves raw text editors take + /// priority here. + /// + /// Only a code that could stand for a printable character can collide with a soft key mapping + /// in the first place. A port is free to use a negative code for Back or Menu, and those cannot + /// be typed, so handing them to the editor would cost the form its back command, pop guard and + /// minimize-on-back behaviour for as long as the editor had focus -- and the editor would drop + /// them anyway. + /// + /// #### Parameters + /// + /// - `keyCode`: the code being dispatched + private boolean focusedHandlesInput(int keyCode) { + if (keyCode < FIRST_PRINTABLE_KEY_CODE || keyCode == DELETE_KEY_CODE) { + return false; + } + return focused != null && focused.consumesRawTextInput() && focused.isEnabled() + && focused.getComponentForm() == this; //NOPMD CompareObjectsWithEquals + } + + /// Space, the lowest code that stands for a character a text component can receive. + private static final int FIRST_PRINTABLE_KEY_CODE = 32; + + /// Delete sits above space in the code space but is not printable. + private static final int DELETE_KEY_CODE = 127; + /// {@inheritDoc} @Override public void keyReleased(int keyCode) { int game = Display.getInstance().getGameAction(keyCode); - if (menuBar.handlesKeycode(keyCode)) { + if (menuBar.handlesKeycode(keyCode) && !focusedHandlesInput(keyCode)) { menuBar.keyReleased(keyCode); return; } diff --git a/CodenameOne/src/com/codename1/ui/Tabs.java b/CodenameOne/src/com/codename1/ui/Tabs.java index 21fc3bd9c67..84e35aa0576 100644 --- a/CodenameOne/src/com/codename1/ui/Tabs.java +++ b/CodenameOne/src/com/codename1/ui/Tabs.java @@ -2469,7 +2469,12 @@ public void actionPerformed(ActionEvent evt) { } } Form parent = getComponentForm(); - parent.clearComponentsAwaitingRelease(); + // A tab can be removed in response to the same pointer gesture + // (e.g. an inspector rebuild). Its global swipe listener may still + // receive the queued drag after deinitialization. + if (parent != null) { + parent.clearComponentsAwaitingRelease(); + } } } } diff --git a/CodenameOne/src/com/codename1/ui/editor/CodePureEditor.java b/CodenameOne/src/com/codename1/ui/editor/CodePureEditor.java index b390e95f58d..d0cf776089d 100644 --- a/CodenameOne/src/com/codename1/ui/editor/CodePureEditor.java +++ b/CodenameOne/src/com/codename1/ui/editor/CodePureEditor.java @@ -74,6 +74,13 @@ public void cmd(String name, String arg) { codeView.setDiagnostics(parseDiagnostics(arg)); return; } + if ("setProtectedMarkers".equals(name)) { + int separator = arg == null ? -1 : arg.indexOf('\n'); + codeView.setProtectedRegionMarkers( + separator < 0 ? null : arg.substring(0, separator), + separator < 0 || separator + 1 >= arg.length() ? null : arg.substring(separator + 1)); + return; + } if ("showCompletions".equals(name)) { handleShowCompletions(arg); return; diff --git a/CodenameOne/src/com/codename1/ui/editor/CodeView.java b/CodenameOne/src/com/codename1/ui/editor/CodeView.java index ea2ec0b5723..6f7939d27ab 100644 --- a/CodenameOne/src/com/codename1/ui/editor/CodeView.java +++ b/CodenameOne/src/com/codename1/ui/editor/CodeView.java @@ -45,6 +45,8 @@ public class CodeView extends EditorView { private ThemePalette palette = ThemePalette.LIGHT; private boolean showLineNumbers = true; private int tabSize = 4; + private String protectedStartMarker; + private String protectedEndMarker; private int[] endStates; private int validUpTo; @@ -114,6 +116,78 @@ public void setDiagnostics(List diagnostics) { repaint(); } + /// Protects every range delimited by matching marker strings from user edits. Passing null or an + /// empty marker clears the protection. Whole-document replacement remains available so generated + /// source can still be refreshed by the host. + public void setProtectedRegionMarkers(String startMarker, String endMarker) { + protectedStartMarker = emptyToNull(startMarker); + protectedEndMarker = emptyToNull(endMarker); + } + + private static String emptyToNull(String value) { + return value == null || value.length() == 0 ? null : value; + } + + @Override + protected void replaceRange(int start, int end, String text, boolean record) { + int clampedStart = getDocument().clamp(start); + int clampedEnd = getDocument().clamp(end); + if (clampedStart > clampedEnd) { + int swap = clampedStart; + clampedStart = clampedEnd; + clampedEnd = swap; + } + if (!isEditAllowed(clampedStart, clampedEnd)) { + return; + } + super.replaceRange(clampedStart, clampedEnd, text, record); + } + + /// Refuses edits that fall inside a protected region, whichever path they arrive through. + /// Input method composition writes to the document directly rather than through + /// `#replaceRange(int, int, String, boolean)`, so checking only there let a composing keyboard + /// insert provisional text into generated source that is meant to be read only. + @Override + protected boolean isEditAllowed(int start, int end) { + int from = Math.min(start, end); + int to = Math.max(start, end); + if (!isProtectedEdit(from, to)) { + return true; + } + // Tell the host rather than dropping the edit in silence. A protected region covering + // most of the document is indistinguishable from an editor that ignores the keyboard, + // so the application needs the chance to explain why nothing happened. + host().fireEditorEvent("protectedEdit", String.valueOf(from)); + return false; + } + + private boolean isProtectedEdit(int start, int end) { + if (protectedStartMarker == null || protectedEndMarker == null) { + return false; + } + String source = getDocument().getText(); + int searchFrom = 0; + while (searchFrom < source.length()) { + int protectedStart = source.indexOf(protectedStartMarker, searchFrom); + if (protectedStart < 0) { + return false; + } + int endMarker = source.indexOf(protectedEndMarker, + protectedStart + protectedStartMarker.length()); + int protectedEnd = endMarker < 0 + ? source.length() : endMarker + protectedEndMarker.length(); + // protectedEnd is the offset just past the end marker, so a caret sitting exactly there + // is already outside the block. Treating it as protected made the first character after + // a generated region impossible to type. + if ((start == end && start >= protectedStart && start < protectedEnd) + || (start < protectedEnd && end > protectedStart)) { + return true; + } + searchFrom = protectedEnd; + } + return false; + } + /// Enables or disables the completion popup. public void setCompletionEnabled(boolean enabled) { this.completionEnabled = enabled; diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 4c07efdb3e7..778a1732c77 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -53,6 +53,7 @@ public class EditorView extends Component implements TextInputClient { private int caret; private int anchor = -1; + private boolean multiKeyModeInstalled; private int composingStart = -1; private int composingEnd = -1; // pre-composition snapshot so the finalized composition forms a single undo unit @@ -814,6 +815,27 @@ public int offsetAtPoint(int absX, int absY) { // ---- editing primitives ---- + /// This view edits text, so key codes reaching it are characters rather than commands. + @Override + protected boolean consumesRawTextInput() { + return true; + } + + /// Whether the given range may be modified. Subclasses that make part of the document read only + /// override this so every mutation path is covered, including input method composition, which + /// writes to the document without going through `#replaceRange(int, int, String, boolean)`. + /// + /// #### Parameters + /// + /// - `start`: inclusive start offset of the edit + /// + /// - `end`: exclusive end offset of the edit + /// + /// **Returns:** true when the edit may proceed + protected boolean isEditAllowed(int start, int end) { + return true; + } + /// Replaces the range `[start, end)` with `text`, updating the caret, history and platform input /// state. /// @@ -1032,20 +1054,25 @@ public void selectAll() { // ---- clipboard ---- - protected void copySelection() { + /// Copies the selection to the clipboard. Public so a host that owns the platform menu bar can + /// route its Copy item here: a menu accelerator consumes the keystroke before the editor sees + /// it, so the host has to perform the operation on the editor's behalf. + public void copySelection() { if (hasSelection()) { Display.getInstance().copyToClipboard(doc.substring(getSelectionStart(), getSelectionEnd())); } } - private void cutSelection() { + /// Cuts the selection to the clipboard. See `#copySelection()` for why this is public. + public void cutSelection() { if (hasSelection() && editable) { copySelection(); replaceRange(getSelectionStart(), getSelectionEnd(), "", true); } } - private void pasteClipboard() { + /// Inserts the clipboard contents at the caret. See `#copySelection()` for why this is public. + public void pasteClipboard() { if (!editable) { return; } @@ -1347,6 +1374,17 @@ protected void laidOut() { @Override protected void focusGained() { super.focusGained(); + // Typing happens on key release, and Display drops any release whose code does not match + // the most recent press. Anyone typing at speed presses the next key before releasing the + // last one, so releases were being discarded and characters silently vanished -- always + // the same ones, because it depends on which pairs overlap. Multi key mode delivers every + // release. Only an editor that found the mode off turns it on, and only that editor turns + // it back off on focus loss; an application that runs in multi key mode by choice is left + // alone rather than having the mode switched off underneath it. + if (!multiKeyModeInstalled && !Display.getInstance().isMultiKeyMode()) { + Display.getInstance().setMultiKeyMode(true); + multiKeyModeInstalled = true; + } startInput(); if (!animRegistered && getComponentForm() != null) { getComponentForm().registerAnimated(this); @@ -1356,9 +1394,22 @@ protected void focusGained() { repaint(); } + /// Switches multi key mode back off, and only if this editor is the one that switched it on. + /// An editor that found the mode already on, or that was initialized but never focused, must + /// not touch it: doing so would switch the mode off underneath whoever does own it, bringing + /// back the dropped keystrokes this exists to prevent. + private void restoreMultiKeyMode() { + if (!multiKeyModeInstalled) { + return; + } + multiKeyModeInstalled = false; + Display.getInstance().setMultiKeyMode(false); + } + @Override protected void focusLost() { super.focusLost(); + restoreMultiKeyMode(); stopInput(); if (animRegistered && getComponentForm() != null) { getComponentForm().deregisterAnimated(this); @@ -1375,7 +1426,9 @@ protected void deinitialize() { // input session here or the stale handle would block startInput on the next focus // gain, leaving the editor deaf until an explicit focus round-trip. The form also // drops animation registrations on deinit, so reset the flag to re-register the - // caret blink when focus returns. + // caret blink when focus returns. Multi key mode is restored here for the same reason: + // focusLost never runs on this path and the setting is global. + restoreMultiKeyMode(); stopInput(); animRegistered = false; } @@ -1800,6 +1853,9 @@ public void setComposingText(String text, int relativeCaret) { } start = doc.clamp(start); end = doc.clamp(end); + if (!isEditAllowed(start, end)) { + return; + } if (composingStart < 0) { // snapshot the pre-composition document so the whole composition (including the // selection this first compose replaces) finalizes as a single undo unit in diff --git a/CodenameOne/src/com/codename1/ui/editor/PureEditor.java b/CodenameOne/src/com/codename1/ui/editor/PureEditor.java index 2ff4c1d6408..3e006b0ca49 100644 --- a/CodenameOne/src/com/codename1/ui/editor/PureEditor.java +++ b/CodenameOne/src/com/codename1/ui/editor/PureEditor.java @@ -89,6 +89,10 @@ public void cmd(String name, String arg) { view.setEditableState("1".equals(arg)); return; } + if ("setCursor".equals(name)) { + view.moveCaret(parseInt(arg, 0), false); + return; + } if ("focus".equals(name)) { view.requestFocus(); return; @@ -108,6 +112,17 @@ public void cmd(String name, String arg) { // remaining rich / code commands are handled by subclasses; ignore here } + private static int parseInt(String value, int defaultValue) { + if (value == null) { + return defaultValue; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException err) { + return defaultValue; + } + } + /// Executes a query returning a string result. Unknown queries return an empty string. /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/editor/RichView.java b/CodenameOne/src/com/codename1/ui/editor/RichView.java index 1a7bbd87714..677aea25b9b 100644 --- a/CodenameOne/src/com/codename1/ui/editor/RichView.java +++ b/CodenameOne/src/com/codename1/ui/editor/RichView.java @@ -220,7 +220,7 @@ protected void pasteClipboardData(Object data) { } @Override - protected void copySelection() { + public void copySelection() { if (!hasSelection()) { return; } diff --git a/CodenameOne/src/com/codename1/ui/layouts/LayeredLayout.java b/CodenameOne/src/com/codename1/ui/layouts/LayeredLayout.java index b0654c66d5e..d8a962a9e9e 100644 --- a/CodenameOne/src/com/codename1/ui/layouts/LayeredLayout.java +++ b/CodenameOne/src/com/codename1/ui/layouts/LayeredLayout.java @@ -340,6 +340,18 @@ private static int getOuterY(Component cmp) { return cmp.getY() - cmp.getStyle().getMarginTop(); } + /// Returns the text baseline a component reports for the given size, or -1 when the component + /// does not describe one. `Component#getBaseline(int, int)` has a non-abstract default that + /// returns the bottom content edge rather than a text baseline, so only components that also + /// describe their baseline resize behavior take part in `#UNIT_BASELINE` alignment. Everything + /// else keeps the historical font-ascent approximation. + private static int declaredBaseline(Component cmp, int width, int height) { + if (cmp == null || cmp.getBaselineResizeBehavior() == Component.BRB_OTHER) { + return -1; + } + return cmp.getBaseline(width, height); + } + /* @@ -2989,9 +3001,17 @@ public int calcPreferredValue(Container parent, Component cmp) { case UNIT_BASELINE: { Style rs = referenceComponent.getStyle(); Style s = cmp.getStyle(); - preferredValue = baseValue + (referenceComponent.getPreferredH() - cmp.getPreferredH()) / 2 - + (rs.getFont().getAscent() - s.getFont().getAscent()) - + (rs.getPaddingTop() - s.getPaddingTop()); + int referenceBaseline = declaredBaseline(referenceComponent, + referenceComponent.getPreferredW(), referenceComponent.getPreferredH()); + int componentBaseline = declaredBaseline(cmp, cmp.getPreferredW(), cmp.getPreferredH()); + if (referenceBaseline >= 0 && componentBaseline >= 0) { + preferredValue = baseValue + rs.getMarginTop() + referenceBaseline + - s.getMarginTop() - componentBaseline; + } else { + preferredValue = baseValue + (referenceComponent.getPreferredH() - cmp.getPreferredH()) / 2 + + (rs.getFont().getAscent() - s.getFont().getAscent()) + + (rs.getPaddingTop() - s.getPaddingTop()); + } break; } default: @@ -3118,14 +3138,21 @@ private int calculate(Component cmp, int top, int left, int bottom, int right) { Component ref = getReferenceComponent(); Style rs = ref.getStyle(); Style s = cmp.getStyle(); - Font rf = rs.getFont(); - Font sf = s.getFont(); - int ra = rf == null || sf == null ? 0 : rf.getAscent(); - int sa = rf == null || sf == null ? 0 : sf.getAscent(); - calculatedValue = baseValue + (ref.getHeight() - cmp.getPreferredH()) / 2 - + (rs.getPaddingTop() - s.getPaddingTop()) - + (rs.getMarginTop() - s.getMarginTop()) - + (ra - sa); + int referenceBaseline = declaredBaseline(ref, ref.getWidth(), ref.getHeight()); + int componentBaseline = declaredBaseline(cmp, cmp.getPreferredW(), cmp.getPreferredH()); + if (referenceBaseline >= 0 && componentBaseline >= 0) { + calculatedValue = baseValue + rs.getMarginTop() + referenceBaseline + - s.getMarginTop() - componentBaseline; + } else { + Font rf = rs.getFont(); + Font sf = s.getFont(); + int ra = rf == null || sf == null ? 0 : rf.getAscent(); + int sa = rf == null || sf == null ? 0 : sf.getAscent(); + calculatedValue = baseValue + (ref.getHeight() - cmp.getPreferredH()) / 2 + + (rs.getPaddingTop() - s.getPaddingTop()) + + (rs.getMarginTop() - s.getMarginTop()) + + (ra - sa); + } } break; @@ -3657,6 +3684,12 @@ public int getAbsolutePixels(Component cmp) { } else { Style rs = ref.getStyle(); Style cs = cmp.getStyle(); + int referenceBaseline = declaredBaseline(ref, ref.getWidth(), ref.getHeight()); + int componentBaseline = declaredBaseline(cmp, cmp.getPreferredW(), cmp.getPreferredH()); + if (referenceBaseline >= 0 && componentBaseline >= 0) { + return baseValue + rs.getMarginTop() + referenceBaseline + - cs.getMarginTop() - componentBaseline; + } Font rf = rs.getFont(); Font cf = cs.getFont(); int ra = rf == null || cf == null ? 0 : rf.getAscent(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index fbe717e5e41..7760837b4d8 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3506,6 +3506,11 @@ public void run() { ignorePressedKeys.add(e.getKeyCode()); return; } + // This press was not blocked, so any record left over from an earlier blocked press of + // the same key is stale: the platform swallowed that release (a system shortcut such as + // Cmd+P on macOS does exactly that) and the record would otherwise eat this key's + // release forever, which looks like one letter of the alphabet no longer existing. + ignorePressedKeys.remove(e.getKeyCode()); if (editorFocused && !editorHandlesKey(e)) { return; } @@ -3526,8 +3531,12 @@ public void run() { } public void keyReleased(KeyEvent e) { - boolean ignore = ignorePressedKeys.contains(e.getKeyCode()); - if (ignore) ignorePressedKeys.remove(e.getKeyCode()); + // A blocked press records its key so the matching release is dropped too. The record is + // honoured whatever modifiers this release reports: releasing Ctrl before P leaves a + // bare P release, and Codename One enters characters on release, so trusting the + // modifiers here typed the shortcut's letter into the focused field. Stale records are + // cleared by the next unblocked press of the same key instead. + boolean ignore = ignorePressedKeys.remove(e.getKeyCode()); if (!isEnabled()) { return; } diff --git a/docs/demos/common/src/main/snippets/developer-guide/basics.xml b/docs/demos/common/src/main/snippets/developer-guide/basics.xml index 9e3bc8f3a19..971835f27f0 100644 --- a/docs/demos/common/src/main/snippets/developer-guide/basics.xml +++ b/docs/demos/common/src/main/snippets/developer-guide/basics.xml @@ -3,11 +3,11 @@ // tag::basics-xml-001[] - - - - - + + + + // end::basics-xml-001[] diff --git a/docs/developer-guide/basics.asciidoc b/docs/developer-guide/basics.asciidoc index 8c337d0a5fd..74ebc1d0365 100644 --- a/docs/developer-guide/basics.asciidoc +++ b/docs/developer-guide/basics.asciidoc @@ -743,6 +743,7 @@ This code is shorthand for resource file loading and for the installation of the * Localization Bundles * Data files +[[working-with-css-themes]] ==== Working with CSS themes Modern Codename One projects ship with a `src/main/css/theme.css` file (or an equivalent stylesheet). Editing this file allows you to define UIIDs using standard CSS syntax together with Codename One–specific extensions such as `cn1-derive` for inheritance and the `#Constants` block for theme constants. Each time you build or run the project, the build tool compiles the CSS into the `theme.res` resource file automatically. Saving the CSS while the simulator is running will also trigger a refresh so you can iterate on styling. @@ -751,14 +752,14 @@ Because the CSS compiler produces the final resource file, you should treat the === GUI builder -The GUI builder allows you to arrange components visually within a UI using drag, property sheets etc. With the GUI builder you can create elaborate, rich UI's without writing the layout code. Forms designed in the builder use auto layout mode by default, which lets you position and resize components on a canvas while using `LayeredLayout` behind the scenes. +The GUI builder arranges components visually: you drag them onto a canvas, position them, and edit their properties in an inspector, without writing the layout code by hand. It's a desktop application that the Codename One Maven plugin launches, so it works the same way whichever IDE you use for the surrounding Java code. Forms designed in the builder use auto layout mode by default, which positions and resizes components on the canvas using `LayeredLayout` behind the scenes. ==== Hello world -The GUI builder is invoked through the Codename One Maven plugin and works the same way regardless of which IDE you use to edit the surrounding Java code. Two goals to know about: +Two goals do the work: -- `cn1:create-gui-form` generates a new GUI form (the `.gui` XML file plus the matching Java source). -- `cn1:guibuilder` opens the GUI builder so you can edit an existing form. +- `cn1:create-gui-form` generates a new GUI form: the `.gui` XML file plus the matching Java source. +- `cn1:guibuilder` opens the GUI builder on the project. Both goals accept a `className` parameter that points at the fully qualified class name of the form. From the root of a multi-module Codename One project, create a new form like this: @@ -781,205 +782,94 @@ To open the form in the GUI builder: include::../demos/common/src/main/snippets/developer-guide/basics.sh[tag=basics-bash-002,indent=0] ---- +Projects generated from the archetype also carry a ready-made shortcut for this: a *CN1 GUI Builder* run configuration in IntelliJ IDEA, an *Open in GUI Builder* action in NetBeans, a *GUI Builder* launch configuration in Eclipse, and a *Tools > GUI Builder* entry in the Visual Studio Code Maven favorites. They all run the same goal. + The full goal reference, including every parameter, lives in <> and <> in the Maven goals appendix. -===== Basic usage +TIP: The GUI builder is a Java 8 artifact, so it runs on whichever JDK already builds your project. -Notice that the UI of the GUIBuilder might change in various ways but the basic concepts should remain the -same. +==== The workspace -The GUI builder is controlled through its main toolbar, notice that your changes will be applied when you click -the #Save# button on the right: +.The GUI builder editing a form created with `cn1:create-gui-form` +image::img/gui-builder-workspace.png[The GUI builder editing a newly created form,scaledwidth=90%] -.The features of the left toolbar -image::img/new-gui-builder-left-toolbar.png[The features of the left toolbar,scaledwidth=50%] +The window has three columns: -.The features of the right toolbar -image::img/new-gui-builder-right-toolbar.png[The features of the right toolbar,scaledwidth=50%] +- *Project forms and palette* -- on the left. The upper half lists every `.gui` file in the project under the *Forms* tab, and the component tree of the form you are editing under the *Hierarchy* tab. Picking a component from the tree is often easier than hitting it on the canvas, particularly when it's behind something else. The lower half is the component palette, with a search field for finding a component by name. -The main UI includes four important parts: +- *Design canvas* -- in the middle. This is a live Codename One rendering of your form, styled by the project's own `theme.css`, so what you see is what the application draws. The buttons above the canvas switch it between phone portrait, phone landscape, tablet, and a full-width desktop canvas. -- #Main Form# -- This is where you place the components of the UI you're building +- *Inspector* -- on the right. It shows the selected component under three tabs: *Properties*, *Layout*, and *Events*. -- #Component Tree# -- This is a logical representation of the component hierarchy within the #Main Form#. It's often easier to pick a component from the tree rather than the form itself +The toolbar carries *Save*, *Undo*, *Redo* and *Refresh* on the left, and *CSS* and *Code* on the right. Changes are written to the `.gui` file when you press *Save*. -- #Property Inspector# -- When you select an element in the tree or form you can see its details here. You can then edit the various details of the component in this area +The two dividers between the columns can be dragged, so you can give the canvas more room while positioning components and give the inspector more room while filling in properties. -- #Palette# -- Components can be dragged from the palette to the #Main Form# and placed in the UI +==== Designing a form +Drag a component from the palette onto the canvas to add it. As you drag, the builder highlights where the component would land: the container that would receive it, and, in auto layout mode, guides showing the edges it would line up with. Dropping outside a valid target leaves the form unchanged. -.The four parts of the GUI builder -image::img/new-gui-builder-component-tree-palette-inspector.png[The four parts of the GUI builder,scaledwidth=50%] +Select a component by clicking it on the canvas or by picking it in the *Hierarchy* tab. A selected component shows resize handles; drag its body to move it, or a handle to resize it. Hold Shift while clicking to select several components at once, which is what the alignment actions in the top-left corner of the canvas operate on: they align edges, centers or baselines, match widths or heights, or disconnect a component from the relationships it has picked up. -You will start by selecting the #Component Palette# and dragging a button into the UI: +Double-click a component that displays text -- or long-press it -- to edit that text in place, without going to the inspector. -.You can drag any component you want from the palette to the main UI -image::img/new-gui-builder-drag-button.png[You can drag any component you want from the palette to the main UI,scaledwidth=50%] +The *Properties* tab holds what the component is: its name, its text, the UIID that connects it to a CSS selector, and the settings that belong to its type, such as the input constraint of a text field or the range of a slider. The *Layout* tab holds where it sits: its position in its parent, the layout manager of a container, and, for auto layout, its alignment reference and its horizontal and vertical size policies. The *Events* tab binds an event, such as a button press, to a method in the companion Java source. -By default the auto layout mode of the GUI builder uses layered layout to position components. Sides can be bound to a component or to the `Form`. You then use distance units to determine the binding behavior. The GUI builder tries to be "smart" and guesses your intention as you drag the components along. +[[auto-layout-mode]] +==== Auto layout mode -When you select the component you placed you can edit the properties of that component: +New forms use auto layout mode. In this mode you place components where you want them rather than accepting the positions a layout manager dictates. -.Properties allow you to customize everything about a component -image::img/new-gui-builder-property-sheet.png[Properties allow you to customize everything about a component,scaledwidth=50%] +.All forms designed in auto layout mode use `LayeredLayout` +NOTE: Auto layout mode is built on the inset support in `LayeredLayout`. Component positioning uses <>, not absolute coordinates. -There are five property sheets per component: +An inset can be fixed (stated in millimetres, pixels or a percentage) or flexible, and it can be measured from the parent or from a sibling component. That's what makes a form designed on one canvas size still work on another: a component pinned to the bottom of the form stays there on a taller screen, and a component pinned below its neighbor follows that neighbor when it moves. -- #Basic Settings# -- These include the basic configuration for a component for example: name, icon, text etc. +The builder chooses insets for you as you drag, and it prefers a relationship with a nearby component over a distance from the edge of the form. The *Layout* tab shows the choice it made and lets you change it: -- #Advanced Settings# -- These include features that aren't as common such as icon gap, mask etc. +- *Alignment reference* -- the component this one is positioned against, or the nearest component if you haven't chosen one. +- *Horizontal size policy* and *Vertical size policy* -- whether the component keeps its preferred size, keeps a fixed size, fills its parent, or matches the size of its reference component. -- #Events# -- By clicking a button in this tab a method will be added to the source file with a callback matching your component name. This binds an event to a button, text field etc. +Resize the canvas with the device buttons above it after every few changes. A form that looks right on one canvas can fall apart on another, and switching between phone portrait and desktop is the quickest way to find out before a device does. -- #Layout# -- You can determine the layout of the parent `Container` here. For auto layout this should stay as layered layout, but you can nest other layout types in here +==== Nested containers and other layouts -- #Style Customization# -- This isn't a theme, if you want to customize the style of a specific component you can do that through this UI. The theme works on a more global/reusable level and this is designed for a specific component +A form doesn't have to be one flat surface. Drag a *Container* from the palette to group components together, then use the *Container layout* picker in the *Layout* tab to give it whichever layout manager suits that part of the form: box, border, flow, grid, table, or layered again. Components dragged into that container are then arranged by it, and the drop guides change to match -- a box layout shows where in the sequence the component would go, a border layout shows which region would receive it, and a table layout shows the cell. -For things like setting the text on the component you can use a convenient "long click" on the component to -edit the text in place as such: +Containers can be nested as far as you need, and a component can be dragged out of one container and into another; the builder rewrites its constraints for the layout it lands in. -.Use the long click to edit the text "in place" -image::img/gui-builder-in-place-edit.png[Use the long click to edit the text "in place",scaledwidth=50%] +==== The theme and the companion source -===== Events +The *CSS* button opens the project stylesheet inside the builder. -IMPORTANT: As of now, the events tab was disabled. https://github.com/codenameone/CodenameOne/issues/3593#issuecomment-1133647486[See issue 3593 for more info.] +.Editing the project stylesheet; the canvas re-renders as you type +image::img/gui-builder-css-editor.png[Editing theme.css inside the GUI builder,scaledwidth=90%] -When a component supports broadcasting events you can bind such events by selecting it, then selecting -the events tab and clicking the button matching the event type +The stylesheet is compiled and applied to the canvas as you edit it, so a color or a font change is visible immediately on the form you are designing. This is the same `src/main/css/theme.css` the application builds with, described in <>. -.The events tab is listed below supported event types can be bound above -image::img/gui-builder-events.png[The events tab is listed below supported event types can be bound above,scaledwidth=50%] +The *Code* button opens the companion Java source. -Once an event is bound the IDE will open to the event code for example: +.The companion Java source, generated from the form +image::img/gui-builder-code-editor.png[The generated companion source,scaledwidth=90%] -[source,java] ----- -include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BasicsJava039Snippet.java[tag=basics-java-039,indent=0] ----- +Everything between the `// ` and `// ` markers is written by the builder from the `.gui` file and is replaced whenever the form is saved. The region between the `// ` markers is yours: event handlers and anything else you add there is preserved across saves. The editor makes the distinction visible, and the status line reminds you which region takes your changes. -TIP: Some IDEs generate the project source code after you explicitly build the project so if your code needs to access variables etc. try building first +Forms scaffolded by older versions of `cn1:create-gui-form` used a different marker style. Opening such a form in the builder converts the file to the format above and keeps the methods you had written. -Within the code you can access all the GUI components you defined with the `gui_` prefix for example: `Button_1` from the -UI is represented as: +==== What's stored on disk -[source,java] ----- -include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BasicsJava040Snippet.java[tag=basics-java-040,indent=0] ----- - -===== Underlying XML - -Saving the project generates an XML file representing the UI into the res directory in the project, the GUI file is created in a matching hierarchy in the project under the `res/guibuilder` directory: +A form is two files that belong together: -.The java and GUI files in the hierarchy -image::img/gui-builder-java-and-gui-files.png[The java and GUI files in the hierarchy,scaledwidth=50%] +- `common/src/main/guibuilder//.gui` -- the XML description of the component tree. This is the file the builder reads and writes, and the one to keep under version control. +- `common/src/main/java//.java` -- the companion Java source. -IMPORTANT: If you refactor (rename or move) the Java file it's connection with the GUI file will break. You need -to move/rename both +IMPORTANT: The two files are connected by name. If you rename or move one, rename or move the other to match, or the builder can no longer pair them. -You can edit the GUI file directly but changes won't map into the GUI builder unless you reopen it. These files should be under version control as they're the main files that change. The GUI builder file for the button and label code looks like this: +The `.gui` file is plain XML and readable enough to review in a diff: [source,xml] ---- include::../demos/common/src/main/snippets/developer-guide/basics.xml[tag=basics-xml-001,indent=0] ---- -This format is simple. The file triggers the following Java source file: - -[source,java] ----- -include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/BasicsJava041Snippet.java[tag=basics-java-041,indent=0] ----- - -WARNING: Don't touch the code within the don't EDIT comments... - -The GUI builder uses the "magic comments" approach where code is generated into those areas to match the XML defined in the GUI builder. Various IDEs generate that code at different times. Some will generate it when you run the app while others will generate it as you save the GUI in the builder. - -You can write code within the class both by using the event mechanism, by writing code in the constructors or through overriding functionality in the base class. - -[[auto-layout-mode]] -==== Auto layout mode - -New forms created with the GUI Builder use auto layout mode. In this mode you can move and resize your components as you see fit. You aren't constrained to the positions dictated by the form's layout manager. - -.All forms designed in auto layout mode use `LayeredLayout` -NOTE: Auto layout mode is built upon the inset support in LayeredLayout. Component positioning uses <>, not absolute positioning - -As an example, drag a button onto a blank form and see what happens. The button will be "selected" initially after adding it, so you'll see its outline and resize handles for adjusting its size and position. You'll also see four floating labels (above, below, to the left, and to the right) that show the corresponding side's inset values and allow you to adjust them. - -When a component is selected you can drag it to reposition it or use the resize handles to change its size. The floating inset labels update as you drag so you can fine tune spacing without leaving the canvas. - -Press the mouse inside the bounds of the button and drag it around to reposition it. You will notice that the inset labels change to reflect the new inset values. If you drag the button close to the edge of the form, the corresponding inset value will change to millimetres. If you move farther away from the edge, it will change to percentage values. - -===== The inset control - -Take a closer look at the inset control (the inset controls are the black buttons that appear to the top, bottom, left, and right of the selected component). - -.The inset control allows you to change the inset size and units, toggle it between fixed and flexible, and link it to another component. -image::img/guibuilder-2-inset-control.png[Inset control,scaledwidth=5%] - -Each control has three sections: - -// vale-skip: Microsoft.Percentages: "percent" is one of the named unit options in the GUI builder dropdown, used here as a unit name on equal footing with "millimetres" and "pixels", not as a quantity. -1. **The inset value drop-down menu**. This shows the current value of the inset (for example: 0mm, 25%, auto, etc.). If you click on this, it will open a menu that will allow you to change the units. If the inset is in millimetres, it will have options for pixels, and percent. If the inset is in percent, it will have options for pixels and millimetres. Etc. It also includes a text field to enter an inset value explicitly. -+ -image::img/guibuilder-2-insets-dropdown-menu.png[Inset drop-down menu,scaledwidth=6%] -2. **The "Link" Button** image:img/guibuilder-2-link-button-unselected.png[Link button,scaledwidth=6%] - If the inset is linked to a reference component, then this button will be highlighted "blue," and hovering over it will highlight the reference component in the UI so that you can see which component it's linked to. Clicking on this button will open a dialog that will allow you to "break" this link. You can drag this button over any component in the form to "link." -// vale-skip: Microsoft.Percentages: "percent" is a named unit option in the GUI builder dropdown, paired with "millimetres", not a quantity. -3. **The "Lock" Button"** image:img/guibuilder-2-inset-fixed-button.png[Inset fixed button,scaledwidth=6%] - This button allows you to toggle the inset between "flexible" (that's: auto) and "fixed" (that's: millimetres or percent). - -===== Auto snap - -Notice the "autosnap" checkbox that appears in the top-right corner of the GUI builder window. - -.Autosnap checkbox -image::img/guibuilder-2-smart-insets-auto-snap-checkboxes.png[Autosnap checkbox,scaledwidth=10%] - -Autosnap does what it sounds like: It automatically snaps two components together when you drag them near each other. This is handy for linking components together without having to explicitly link them (using the "link" button). This feature is turned on by default. If autosnap is turned off, you can still start a "snap" by holding down the ALT/Option key on your keyboard during the drag. - -===== Smart insets - -Beside the "autosnap" checkbox is another checkbox named `Smart Insets`. - -.Smart insets checkbox -image::img/guibuilder-2-smart-insets-auto-snap-checkboxes.png[Smart insets checkbox,scaledwidth=10%] - -Smart Inset uses some heuristics during a drag to try to determine how the insets should be linked. The heuristics are basic (it tries to link to the nearest neighbor component usually), but you will be working on improving this for future releases. This feature is turned off by default while it's still being refined. The goal is to improve this to the point where it *always* makes the correct link choices - at which time you will be able to use the GUI builder without having any knowledge of insets or reference components. - -===== The widget control pad - -.Widget control pad -image::img/guibuilder-2-widget-control-pad.png[Widget control pad,scaledwidth=10%] - -When a component is selected, you should see a black floating panel appear in the lower right of the screen. - -This is the widget control pad, and it provides an alternative view of the component's links. It also provides a useful list of incoming links (that's: components that "depend on" this component's positioning). Sometimes, you may want to disconnect incoming links so that you can drag the component without affecting the position of dependent components. - -This control pad also includes game-pad-like controls (up, down, left, right), that allow you to "tab" the component to the next guide in that direction. Tab positions exist at component edges in the form. This is useful for aligning components with each other. - -===== Keyboard Short-Cuts - -1. **Arrow Keys** - Use the up/down/left/right arrow keys to nudge the selected component a little bit at a time. This is a convenient way to move the component to a position that's more precise than can be achieved with a mouse drag. -2. **Arrow Keys + SHIFT** - Hold down the SHIFT key while pressing an arrow key and it will "tab" the component to the next tab marker. The form has implicit tab markers at the edge of each component on the form. -3. **ALT/Option Key + Click or Drag** - Holding down the option/alt key while clicking or dragging a component will resulting in "snapping" behaviour even if autosnap is turned off. - -===== Sub-Containers - -Sometimes, you may need to add sub-containers to your form to aid in grouping your components together. You can drag a container onto your form using the "Container" palette item (under "Core Components"). The default layout the subcontainer will be LayeredLayout so that you're able to position components within the sub-container with precision, like on the root container. - -You can also change the layout of subcontainers to another classical layout manager (for example: grid layout, box layout, etc.) and drag components directly into it. This is useful if parts of your form lend themselves to a different layout. As an example, drag a container onto the canvas that uses BoxLayout Y. (You can find this under the "Containers" section of the component palette). - -Drag the button (that was earlier on the form) over that container, and you should see a drop-zone become highlighted. - -.Dropping container on child container with box layout y -image::img/guibuilder-2-subcontainer-add-child.png[Dropping container on child container with box layout y,scaledwidth=25%] - -You can drop the button directly there. You can As you drag more components into the sub-container, you'll see them automatically laid out vertically. - -.Box Layout Y dropping 2nd child -image::img/guibuilder-2-subcontainer-add-child-2.png[Box Layout Y dropping 2nd child,scaledwidth=25%] - -===== The canvas resize tool - -When designing a UI with the GUI builder it's ** important that you periodically test the form's "resizing" behavior so that you know how it will behave on different devices. Components may appear to be positioned when the canvas is one size, but become out of whack when the container is resized. After every manipulation you perform, it's good practice to drag the canvas resize tool (the button in the lower right corner of the GUI builder) smaller and bigger so you can see how the positions are changed. If things grow out of whack, you may need to toggle an inset between fixed and auto, or add a link between some components so that the resizing behavior matches your expectations. +You can edit it by hand, but reopen the form afterward: the builder doesn't watch the file while it's running. diff --git a/docs/developer-guide/img/gui-builder-code-editor.png b/docs/developer-guide/img/gui-builder-code-editor.png new file mode 100644 index 00000000000..7cf0d63e182 Binary files /dev/null and b/docs/developer-guide/img/gui-builder-code-editor.png differ diff --git a/docs/developer-guide/img/gui-builder-css-editor.png b/docs/developer-guide/img/gui-builder-css-editor.png new file mode 100644 index 00000000000..b652d3f6b5e Binary files /dev/null and b/docs/developer-guide/img/gui-builder-css-editor.png differ diff --git a/docs/developer-guide/img/gui-builder-events.png b/docs/developer-guide/img/gui-builder-events.png deleted file mode 100644 index 0ef81c631bb..00000000000 Binary files a/docs/developer-guide/img/gui-builder-events.png and /dev/null differ diff --git a/docs/developer-guide/img/gui-builder-in-place-edit.png b/docs/developer-guide/img/gui-builder-in-place-edit.png deleted file mode 100644 index 9529932c45a..00000000000 Binary files a/docs/developer-guide/img/gui-builder-in-place-edit.png and /dev/null differ diff --git a/docs/developer-guide/img/gui-builder-java-and-gui-files.png b/docs/developer-guide/img/gui-builder-java-and-gui-files.png deleted file mode 100644 index c8a81c40071..00000000000 Binary files a/docs/developer-guide/img/gui-builder-java-and-gui-files.png and /dev/null differ diff --git a/docs/developer-guide/img/gui-builder-workspace.png b/docs/developer-guide/img/gui-builder-workspace.png new file mode 100644 index 00000000000..f1ad23cec27 Binary files /dev/null and b/docs/developer-guide/img/gui-builder-workspace.png differ diff --git a/docs/developer-guide/img/guibuilder-2-inset-control.png b/docs/developer-guide/img/guibuilder-2-inset-control.png deleted file mode 100644 index a4f9590d404..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-inset-control.png and /dev/null differ diff --git a/docs/developer-guide/img/guibuilder-2-inset-fixed-button.png b/docs/developer-guide/img/guibuilder-2-inset-fixed-button.png deleted file mode 100644 index 60243c6a14e..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-inset-fixed-button.png and /dev/null differ diff --git a/docs/developer-guide/img/guibuilder-2-insets-dropdown-menu.png b/docs/developer-guide/img/guibuilder-2-insets-dropdown-menu.png deleted file mode 100644 index e177af83a70..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-insets-dropdown-menu.png and /dev/null differ diff --git a/docs/developer-guide/img/guibuilder-2-link-button-unselected.png b/docs/developer-guide/img/guibuilder-2-link-button-unselected.png deleted file mode 100644 index 26e3705b470..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-link-button-unselected.png and /dev/null differ diff --git a/docs/developer-guide/img/guibuilder-2-smart-insets-auto-snap-checkboxes.png b/docs/developer-guide/img/guibuilder-2-smart-insets-auto-snap-checkboxes.png deleted file mode 100644 index a133f369415..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-smart-insets-auto-snap-checkboxes.png and /dev/null differ diff --git a/docs/developer-guide/img/guibuilder-2-subcontainer-add-child-2.png b/docs/developer-guide/img/guibuilder-2-subcontainer-add-child-2.png deleted file mode 100644 index 57ecc6b17e1..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-subcontainer-add-child-2.png and /dev/null differ diff --git a/docs/developer-guide/img/guibuilder-2-subcontainer-add-child.png b/docs/developer-guide/img/guibuilder-2-subcontainer-add-child.png deleted file mode 100644 index edb03dcf8ae..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-subcontainer-add-child.png and /dev/null differ diff --git a/docs/developer-guide/img/guibuilder-2-widget-control-pad.png b/docs/developer-guide/img/guibuilder-2-widget-control-pad.png deleted file mode 100644 index a722c90b6f7..00000000000 Binary files a/docs/developer-guide/img/guibuilder-2-widget-control-pad.png and /dev/null differ diff --git a/docs/developer-guide/img/new-gui-builder-component-tree-palette-inspector.png b/docs/developer-guide/img/new-gui-builder-component-tree-palette-inspector.png deleted file mode 100644 index d7d4da2abc4..00000000000 Binary files a/docs/developer-guide/img/new-gui-builder-component-tree-palette-inspector.png and /dev/null differ diff --git a/docs/developer-guide/img/new-gui-builder-drag-button.png b/docs/developer-guide/img/new-gui-builder-drag-button.png deleted file mode 100644 index 5a355952efe..00000000000 Binary files a/docs/developer-guide/img/new-gui-builder-drag-button.png and /dev/null differ diff --git a/docs/developer-guide/img/new-gui-builder-left-toolbar.png b/docs/developer-guide/img/new-gui-builder-left-toolbar.png deleted file mode 100644 index cd2b010bc3d..00000000000 Binary files a/docs/developer-guide/img/new-gui-builder-left-toolbar.png and /dev/null differ diff --git a/docs/developer-guide/img/new-gui-builder-property-sheet.png b/docs/developer-guide/img/new-gui-builder-property-sheet.png deleted file mode 100644 index 6763df7a486..00000000000 Binary files a/docs/developer-guide/img/new-gui-builder-property-sheet.png and /dev/null differ diff --git a/docs/developer-guide/img/new-gui-builder-right-toolbar.png b/docs/developer-guide/img/new-gui-builder-right-toolbar.png deleted file mode 100644 index eecd5041f98..00000000000 Binary files a/docs/developer-guide/img/new-gui-builder-right-toolbar.png and /dev/null differ diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_GUI_Builder.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_GUI_Builder.xml new file mode 100644 index 00000000000..20a1b57faf8 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/runConfigurations/CN1_GUI_Builder.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json b/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json index aa2335dd230..11b32105249 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/settings.json @@ -7,6 +7,10 @@ "alias": "Tools > Codename One Settings", "command": "\"cn1:settings\" \"-U\" \"-e\"" }, + { + "alias": "Tools > GUI Builder", + "command": "\"cn1:guibuilder\" \"-U\" \"-e\"" + }, { "alias": "Tools > Certificate Wizard", "command": "\"cn1:certificatewizard\" \"-U\" \"-e\"" diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/eclipse/__mainName__ GUI Builder.launch b/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/eclipse/__mainName__ GUI Builder.launch new file mode 100644 index 00000000000..cf7714a1fd2 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/tools/eclipse/__mainName__ GUI Builder.launch @@ -0,0 +1,27 @@ +#set ( $d = "$") + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java index b2b2318d36e..a25d865fc70 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java @@ -113,6 +113,15 @@ public abstract class AbstractCN1Mojo extends AbstractMojo { @Parameter(required = true, readonly = true, defaultValue = "${project.remoteArtifactRepositories}") protected List remoteRepositories; + /** + * The legacy resolver used by the goals below does not read the session's offline flag by + * itself, so {@code mvn -o} still let it reach the network and, worse, install what it found + * over an artifact the same build had just produced locally. Every resolution request in this + * class passes this through. + */ + @Parameter(required = true, readonly = true, defaultValue = "${settings.offline}") + protected boolean offline; + /** * Version of the deprecated Resource Editor that {@code cn1:designer} resolves. * It is frozen rather than tracking the framework version, so it does not follow @@ -483,6 +492,7 @@ protected File getJar(Artifact artifact) { ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() + .setOffline(offline) .setLocalRepository(localRepository) .setRemoteRepositories(new ArrayList<>(remoteRepositories)) .setResolveTransitively(true) @@ -697,6 +707,7 @@ protected File findArtifactFile(Artifact artifact) { ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() + .setOffline(offline) .setLocalRepository(localRepository) .setRemoteRepositories(new ArrayList<>(remoteRepositories)) .setResolveTransitively(true) @@ -964,6 +975,7 @@ protected String getCssCliClasspath() throws MojoExecutionException { List files = new ArrayList(); addCssCliJar(files, artifact); ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() + .setOffline(offline) .setLocalRepository(localRepository) .setRemoteRepositories(new ArrayList<>(remoteRepositories)) .setResolveTransitively(true) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CreateGuiFormMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CreateGuiFormMojo.java index 9414c618607..35232afd6d8 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CreateGuiFormMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CreateGuiFormMojo.java @@ -1,8 +1,26 @@ /* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ + package com.codename1.maven; import java.io.File; @@ -101,21 +119,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException String fileName = className.contains(".") ? className.substring(className.lastIndexOf(".") + 1) : className; - String javaSource = "package " + className.substring(0, className.lastIndexOf(".")) + ";\n" - + "public class " + fileName + " extends com.codename1.ui." + getGUIType() + " {\n" - + " public " + fileName + "() {\n" - + " this(com.codename1.ui.util.Resources.getGlobalResources());\n" - + " }\n" - + " \n" - + " public " + fileName + "(com.codename1.ui.util.Resources resourceObjectInstance) {\n" - + " initGuiBuilderComponents(resourceObjectInstance);\n" - + " }\n" - + " \n" - + "//-- DON'T EDIT BELOW THIS LINE!!!\n" - + " private void initGuiBuilderComponents(com.codename1.ui.util.Resources resourceObjectInstance) {\n" - + " }\n" - + "//-- DON'T EDIT ABOVE THIS LINE!!!\n" - + "}\n"; + String javaSource = scaffoldedSource(className.substring(0, className.lastIndexOf(".")), fileName); String xmlGUISource; if (getGUIType().equalsIgnoreCase("Container")) { @@ -140,6 +144,46 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } + /** + * Builds the companion source in the format the GUI Builder reads and rewrites. The editor + * replaces everything between the gui-builder-generated markers on every save and preserves the + * user-code region, so a scaffold without those markers is one the editor cannot write the + * designed component tree into. + * + * @param packageName the package of the generated form + * @param fileName the simple class name of the generated form + * @return the Java source to scaffold + */ + String scaffoldedSource(String packageName, String fileName) { + boolean container = "Container".equalsIgnoreCase(getGUIType()); + String superClass = container ? "Container" : "Dialog".equalsIgnoreCase(getGUIType()) ? "Dialog" : "Form"; + String layout = "LayeredLayout".equals(getLayout()) ? "new LayeredLayout()" : "new FlowLayout()"; + String superCall = container ? "super(" + layout + ");" : "super(\"" + fileName + "\", " + layout + ");"; + return "// \n" + + "package " + packageName + ";\n" + + "\n" + + "import com.codename1.ui.*;\n" + + "import com.codename1.ui.events.ActionEvent;\n" + + "import com.codename1.ui.layouts.*;\n" + + "\n" + + "// Generated live from " + fileName + ".gui.\n" + + "public class " + fileName + " extends " + superClass + " {\n" + + " public " + fileName + "() {\n" + + " " + superCall + "\n" + + " buildUI();\n" + + " }\n" + + "\n" + + " private void buildUI() {\n" + + " }\n" + + "\n" + + "// \n" + + "// \n" + + "// \n" + + "// \n" + + "}\n" + + "// \n"; + } + protected String getGUIType() { return guiType; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java index 5694b242fd4..6465949a11b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java @@ -1,268 +1,343 @@ /* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. */ package com.codename1.maven; -import java.io.DataInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.UUID; +import org.apache.commons.io.FileUtils; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; +import org.apache.maven.artifact.resolver.ArtifactResolutionResult; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; -import org.apache.maven.shared.invoker.DefaultInvocationRequest; -import org.apache.maven.shared.invoker.DefaultInvoker; -import org.apache.maven.shared.invoker.InvocationRequest; -import org.apache.maven.shared.invoker.Invoker; -import org.apache.maven.shared.invoker.MavenInvocationException; import org.apache.tools.ant.taskdefs.Java; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + /** - * Goal to open the gui builder. - * @author shannah + * Opens the modern standalone Codename One GUI Builder for every GUI form in the project. + * The editor is resolved from Maven using the same distribution model as Codename One + * Settings; no downloaded {@code ~/.codenameone/guibuilder.jar} is used. + * + *
mvn cn1:guibuilder [-DclassName=com.example.MyForm]
*/ @Mojo(name = "guibuilder") public class OpenGuiBuilderMojo extends AbstractCN1Mojo { - private File guibuilderInput = new File(System.getProperty("user.home") + File.separator + ".guiBuilder" + File.separator + "guibuilder.input"); + private static final String LAUNCHED_PROPERTY = "com.codename1.maven.OpenGuiBuilderMojo.launched"; - @Parameter(property="className", required=true) + /** The editor is compiled for this Java release, so an older forked JVM cannot load it. */ + private static final int REQUIRED_JAVA_VERSION = 8; + + /** {@code --add-exports} is a Java 9 option; passing it to an 8 JVM stops it from starting. */ + private static final int MODULE_OPTIONS_VERSION = 9; + + /** Optional fully-qualified form to select initially. */ + @Parameter(property = "className", required = false) private String className; + + @Parameter(property = "guibuilder.spawn", required = false, defaultValue = "true") + private boolean spawn; + @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (Boolean.getBoolean(LAUNCHED_PROPERTY)) { + getLog().debug("Skipping guibuilder: already launched in this Maven invocation"); + return; + } if (!isCN1ProjectDir()) { + getLog().debug("Skipping guibuilder: not a Codename One project directory"); return; } - try { - + requireModernJdk(); + System.setProperty(LAUNCHED_PROPERTY, "true"); - File sourceFile = findSourceFile(className); - if (!sourceFile.exists()) { - throw new MojoExecutionException("Cannot find source file "+sourceFile); - } - File guiFile = findGuiFile(className); - if (!guiFile.exists()) { - throw new MojoExecutionException("Cannot find gui fuild "+guiFile); - } - - File resourceFile = getProjectResourceFile(); - if (!resourceFile.exists()) { - if (isCSSProject()) { - // If it is a CSS project, we may simply need to compile the CSS since the theme.res - // will be located in the compiled classes directory - InvocationRequest request = new DefaultInvocationRequest(); - //request.setPomFile( new File( "/path/to/pom.xml" ) ); - request.setGoals( Collections.singletonList( "cn1:css" ) ); - + File projectDir = getCN1ProjectDir(); + File guiDir = new File(projectDir, "src" + File.separator + "main" + File.separator + "guibuilder"); + File sourceDir = new File(projectDir, "src" + File.separator + "main" + File.separator + "java"); + File cssFile = new File(projectDir, "src" + File.separator + "main" + File.separator + "css" + File.separator + "theme.css"); + guiDir.mkdirs(); - Invoker invoker = new DefaultInvoker(); - try { - getLog().info("theme.res file not found. Trying to compile CSS to generate it now"); - invoker.execute( request ); - } catch (MavenInvocationException ex) { - getLog().error("Failed to compile CSS"); - throw new MojoExecutionException(ex.getMessage(), ex); + File runtimeDir = new File(System.getProperty("user.home"), ".codenameoneGUIBuilder"); + runtimeDir.mkdirs(); + File input = new File(runtimeDir, "guibuilder-" + UUID.randomUUID() + ".input"); + writeBinding(input, projectDir, guiDir, sourceDir, cssFile); - } - if (!resourceFile.exists()) { - throw new MojoExecutionException("Still cannot find resource file at "+resourceFile+" even after compiling project CSS"); - - } - } else { - throw new MojoExecutionException("Cannot find project resource file "+resourceFile); - } - } - openInGuiBuilder(project.getName(), sourceFile, resourceFile, guiFile); - } catch (IOException ex) { - getLog().error("Failed to open form"); - throw new MojoExecutionException("Failed to open form", ex); + ToolClasspath classpath = getGuiBuilderClasspath(); + getLog().info("Launching Codename One GUI Builder bound to " + projectDir); + if (shouldSpawn()) { + launchDetached(classpath, runtimeDir, input, projectDir); + return; } + Java java = createJava(); + java.setFork(true); + java.setClassname("com.codename1.guibuilder.CodenameOneGUIBuilderLauncher"); + java.createClasspath().setPath(joinClasspath(classpath.files)); + for (String arg : desktopIdentityArgs()) { + java.createJvmarg().setValue(arg); + } + java.createJvmarg().setValue("-Dguibuilder.input=" + input.getAbsolutePath()); + for (String arg : forwardedGuiBuilderProperties()) { + java.createJvmarg().setValue(arg); + } + java.executeJava(); } - - private boolean isCSSProject() { - return ("true".equals(properties.getProperty("codename1.cssTheme", null))); - } - - private File getProjectResourceFile() { - if (isCSSProject()) { - return new File(project.getBuild().getOutputDirectory() + File.separator + "theme.res"); + + /** + * The GUI Builder is a Java 8 artifact. The spawned process writes only to its log file, so + * without this check an older Maven JVM fails with an UnsupportedClassVersionError that never + * reaches the console. + */ + private void requireModernJdk() throws MojoFailureException { + int version = javaFeatureVersion(); + if (version >= REQUIRED_JAVA_VERSION) { + return; } - return new File(project.getBasedir() + File.separator + "src" + File.separator + "main" + File.separator + "resources" + File.separator + "theme.res"); + throw new MojoFailureException("The Codename One GUI Builder needs JDK " + + REQUIRED_JAVA_VERSION + " or newer, but Maven is running on " + + System.getProperty("java.version", String.valueOf(version)) + " (" + + System.getProperty("java.home") + ").\n" + + "Point JAVA_HOME at a JDK " + REQUIRED_JAVA_VERSION + + "+ installation (for example Eclipse Temurin from https://adoptium.net) and run " + + "mvn cn1:guibuilder again."); } - - private File getGuiBuilderSourcesDir() { - return new File(project.getBasedir() + File.separator + "src" + File.separator + "main" + File.separator + "guibuilder"); + + static int javaFeatureVersion() { + String version = System.getProperty("java.specification.version", ""); + if (version.startsWith("1.")) { + version = version.substring(2); + } + int dot = version.indexOf('.'); + if (dot > 0) { + version = version.substring(0, dot); + } + try { + return Integer.parseInt(version.trim()); + } catch (NumberFormatException ex) { + return 0; + } } - - private File findGuiFile(String fullyQualifiedClassName) { - return new File(getGuiBuilderSourcesDir(), fullyQualifiedClassName.replace(".", File.separator) + ".gui"); + + /** + * Forwards {@code guibuilder.*} system properties (MCP port, canvas mode, dark mode, initial + * selection, editor to open) from the Maven invocation to the editor JVM, so + * {@code mvn cn1:guibuilder -Dguibuilder.mcp.port=18349} works. The {@code guibuilder.input} + * binding and the {@code guibuilder.spawn} launch flag are owned by this mojo and never + * forwarded. + */ + List forwardedGuiBuilderProperties() { + List args = new ArrayList(); + for (String key : System.getProperties().stringPropertyNames()) { + if (key.startsWith("guibuilder.") + && !key.equals("guibuilder.input") + && !key.equals("guibuilder.spawn")) { + args.add("-D" + key + "=" + System.getProperty(key)); + } + } + return args; } - - private File findSourceFile(String fullyQualifiedClassName) { - return new File( - project.getBasedir() + File.separator + "src" + File.separator + - "main" + File.separator + "java" + File.separator + - fullyQualifiedClassName.replace(".", File.separator) + ".java" - ); + + /** + * Names the process for the dock, taskbar and window manager, and opens the JDK packages the + * JavaSE port needs on Java 9 and newer, matching cn1:settings and cn1:certificate-wizard. + */ + List desktopIdentityArgs() { + List args = new ArrayList(); + args.add("-Dapple.awt.application.name=Codename One GUI Builder"); + args.add("-Dcom.apple.mrj.application.apple.menu.about.name=Codename One GUI Builder"); + args.add("-Dsun.awt.application.name=Codename One GUI Builder"); + args.add("-Dsun.awt.X11.XWMClass=CodenameOneGUIBuilder"); + if (javaFeatureVersion() >= MODULE_OPTIONS_VERSION) { + // On 8 these packages are already reachable and the option itself is unrecognized, + // which stops the forked JVM before it prints anything the user would see. + args.add("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED"); + args.add("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED"); + } + if (isMacOs()) { + args.add("-Xdock:name=Codename One GUI Builder"); + } + return args; } - - private File getGuiBuilderJar() { - File home = new File(System.getProperty("user.home")); - File codenameone = new File(home, ".codenameone"); - File settingsJar = new File(codenameone, "guibuilder.jar"); - - return settingsJar; + + private static boolean isMacOs() { + return System.getProperty("os.name", "").toLowerCase().contains("mac"); } - - - - static String xmlize(String s) { - s = s.replace("&", "&"); - s = s.replace("<", "<"); - s = s.replace(">", ">"); - s = s.replace("\"", """); - int charCount = s.length(); - for(int iter = 0 ; iter < charCount ; iter++) { - char c = s.charAt(iter); - if(c > 127) { - // we need to localize the string... - StringBuilder b = new StringBuilder(); - for(int counter = 0 ; counter < charCount ; counter++) { - c = s.charAt(counter); - if(c > 127) { - b.append("&#x"); - b.append(Integer.toHexString(c)); - b.append(";"); - } else { - b.append(c); - } - } - return b.toString(); - } + + @Override + protected boolean isCN1ProjectDir() { + File cn1 = getCN1ProjectDir(); + if (cn1 == null || project == null || project.getBasedir() == null) return false; + try { + File current = project.getBasedir().getCanonicalFile(); + File projectDir = cn1.getCanonicalFile(); + return projectDir.equals(current) || projectDir.equals(new File(current, "common").getCanonicalFile()); + } catch (IOException ex) { + return false; } - return s; } - - public void openInGuiBuilder(String projectName, final File javaSourceFile, File projectResourceFile, File guiFile) throws IOException, MojoExecutionException { - try { - File guiBuilderDirectory = new File(System.getProperty("user.home") + File.separator + ".guiBuilder"); - File cn1Directory = new File(System.getProperty("user.home") + File.separator + ".cn1"); - File codenameOneDirectory = new File(System.getProperty("user.home") + File.separator + ".codenameone"); - guiBuilderDirectory.mkdirs(); - cn1Directory.mkdirs(); - codenameOneDirectory.mkdirs(); - if(!guiBuilderDirectory.exists()) { - throw new FileNotFoundException("Couldn't find or create the GUI builder directory within your home directory specifically: " + guiBuilderDirectory.getAbsolutePath()); - - } - if(!cn1Directory.exists()) { - throw new FileNotFoundException("Couldn't find or create the cn1 directory within your home directory specifically: " + cn1Directory.getAbsolutePath()); - - } - if(!codenameOneDirectory.exists()) { - throw new FileNotFoundException("Couldn't find or create the codename1 directory within your home directory specifically: " + codenameOneDirectory.getAbsolutePath()); - } - String connectionId = UUID.randomUUID().toString(); - final File runningFile = new File(System.getProperty("user.home") + File.separator + ".guiBuilder" + File.separator + connectionId); - final File outputFile = new File(System.getProperty("user.home") + File.separator + ".guiBuilder" + File.separator + connectionId + ".ouput"); - runningFile.getParentFile().mkdirs(); - FileOutputStream r = new FileOutputStream(runningFile); - r.write(0); - r.close(); - FileOutputStream fos = new FileOutputStream(guibuilderInput); - String formName = javaSourceFile.getName(); - formName = formName.substring(0, formName.length() - 5); - fos.write(("\n" + - "\n").getBytes(StandardCharsets.UTF_8)); - fos.close(); - launchGuiBuilderApp(); - new Thread() { - private long lastModified = 0; - public void run() { - while(runningFile.exists()) { - if(outputFile.exists() && lastModified != outputFile.lastModified()) { - try { - Thread.sleep(100); - } catch(InterruptedException e) {} - try { - FileInputStream fis = new FileInputStream(outputFile); - byte[] data = new byte[(int)outputFile.length()]; - new DataInputStream(fis).readFully(data); - fis.close(); - lastModified = outputFile.lastModified(); - String d = new String(data, StandardCharsets.UTF_8); - if(d.endsWith("DataChangeEvent")) { - gotoSourceFileLine(javaSourceFile, "void " + d, "\n public void " + d + "(com.codename1.ui.Component cmp, int type, int index) {\n }\n"); - } else { - if(d.endsWith("Command")) { - gotoSourceFileLine(javaSourceFile, "void " + d, "\n public void " + d + "(com.codename1.ui.events.ActionEvent ev, Command cmd) {\n }\n"); - } else { - if(d.endsWith("ListModel")) { - gotoSourceFileLine(javaSourceFile, "ListModel " + d, "\n public com.codename1.ui.list.ListModel " + d + "() {\n }\n"); - } else { - gotoSourceFileLine(javaSourceFile, "void " + d, "\n public void " + d + "(com.codename1.ui.events.ActionEvent ev) {\n }\n"); - } - } - } - outputFile.delete(); - } catch(IOException err) { - err.printStackTrace(); - } - } - try { - Thread.sleep(1000); - } catch(InterruptedException e) {} - } - } - }.start(); + private boolean shouldSpawn() { + String legacy = System.getProperty("spawn"); + return legacy == null ? spawn : Boolean.parseBoolean(legacy); + } - } catch(IOException err) { - handleGUIBuilderError(err, "Error launching GUI builder: " + err); + void writeBinding(File input, File projectDir, File guiDir, File sourceDir, File cssFile) + throws MojoExecutionException { + StringBuilder content = new StringBuilder(); + content.append("# Codename One GUI Builder project binding\n"); + content.append("projectDir=").append(projectDir.getAbsolutePath()).append('\n'); + content.append("guiDir=").append(guiDir.getAbsolutePath()).append('\n'); + content.append("sourceDir=").append(sourceDir.getAbsolutePath()).append('\n'); + content.append("cssFile=").append(cssFile.getAbsolutePath()).append('\n'); + if (className != null && className.trim().length() > 0) { + content.append("initialForm=").append(className.trim()).append('\n'); + } + try { + FileUtils.write(input, content.toString(), StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write GUI Builder project binding", ex); } - } - - /** - * Opens the source file, brings the IDE to the foreground. If methodSig doesn't exist it inserts the method - * prototype at the end of the source file before the last curly bracket - */ - public void gotoSourceFileLine(File javaSource, String methodSig, String methodPrototype) { - + + private void launchDetached(ToolClasspath classpath, File runtimeDir, File input, File projectDir) + throws MojoExecutionException { + List command = new ArrayList(); + command.add(javaExecutable()); + command.addAll(desktopIdentityArgs()); + command.add("-Dguibuilder.input=" + input.getAbsolutePath()); + command.addAll(forwardedGuiBuilderProperties()); + command.add("-cp"); + command.add(joinClasspath(classpath.files)); + command.add("com.codename1.guibuilder.CodenameOneGUIBuilderLauncher"); + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(projectDir); + builder.redirectErrorStream(true); + builder.redirectOutput(ProcessBuilder.Redirect.appendTo(new File(runtimeDir, "guibuilder.log"))); + try { + builder.start(); + getLog().info("GUI Builder launched in the background. Log: " + new File(runtimeDir, "guibuilder.log")); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to launch Codename One GUI Builder", ex); + } } - public void handleGUIBuilderError(Exception err, String message) { - + private ToolClasspath getGuiBuilderClasspath() throws MojoExecutionException, MojoFailureException { + Artifact artifact = getArtifact("com.codenameone", "codenameone-guibuilder"); + if (artifact == null) { + artifact = repositorySystem.createArtifact("com.codenameone", "codenameone-guibuilder", pluginVersion(), "jar"); + } + List files = new ArrayList(); + ArtifactResolutionResult result = repositorySystem.resolve(new ArtifactResolutionRequest() + .setLocalRepository(localRepository) + .setRemoteRepositories(new ArrayList(remoteRepositories)) + .setResolveTransitively(true) + // Maven documents -o as "work offline"; the legacy resolver does not read the + // session flag by itself, so without this mvn -o cn1:guibuilder still reaches the + // network and can refresh a snapshot over the jar this build just produced. + .setOffline(offline) + .setArtifact(artifact)); + addArtifact(files, artifact); + if (result != null && result.getArtifacts() != null) { + for (Artifact resolved : result.getArtifacts()) addArtifact(files, resolved); + } + // A cached main jar with a missing transitive dependency leaves this list nonempty, and in + // detached mode the goal would report a successful launch while the process died in + // guibuilder.log with a NoClassDefFoundError nobody goes looking for. + String incomplete = resolutionFailure(result); + if (incomplete != null) { + throw new MojoFailureException("The GUI Builder could not be fully resolved: " + incomplete + + "\nRun mvn -U cn1:guibuilder to refresh it, or drop -o if you are building offline."); + } + if (files.isEmpty()) { + throw new MojoFailureException("Could not resolve the GUI Builder (com.codenameone:codenameone-guibuilder:" + + pluginVersion() + "). It is distributed through Maven Central alongside the Codename One plugin.\n" + + "To work on the editor, run:\n" + + " cd scripts/guibuilder && mvn -Pexecutable-jar -pl javase -am package -Dcodename1.platform=javase"); + } + return new ToolClasspath(files); } /** - * Launches the actual GUI builder jar executable - * @throws org.apache.maven.plugin.MojoExecutionException + * Describes what the resolver could not produce, or null when the result is complete. + * + * @param result the resolution result to inspect + * @return a human readable description of the first problem found */ - public void launchGuiBuilderApp() throws MojoExecutionException{ - - updateCodenameOne(false, getGuiBuilderJar()); - Java java = createJava(); - java.setFork(true); - if ("true".equals(System.getProperty("spawn", "true"))) { - java.setSpawn(true); + static String resolutionFailure(ArtifactResolutionResult result) { + if (result == null) return null; + if (result.hasMissingArtifacts()) { + return "missing " + result.getMissingArtifacts(); } - java.setJar(getGuiBuilderJar()); - java.executeJava(); + if (result.hasExceptions()) { + Exception first = (Exception) result.getExceptions().get(0); + return first.getMessage(); + } + return null; + } + + private static void addArtifact(List files, Artifact artifact) { + if (artifact == null || artifact.getFile() == null || !"jar".equals(artifact.getType())) return; + File file = artifact.getFile().getAbsoluteFile(); + if (file.exists() && !files.contains(file)) files.add(file); + } + + private String pluginVersion() { + if (pluginArtifacts != null) { + for (Artifact artifact : pluginArtifacts) { + if ("com.codenameone".equals(artifact.getGroupId()) + && "codenameone-maven-plugin".equals(artifact.getArtifactId())) return artifact.getVersion(); + } + } + return project.getProperties().getProperty("cn1.plugin.version", + project.getProperties().getProperty("cn1.version", "8.0-SNAPSHOT")); + } + + private String javaExecutable() { + boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win"); + return new File(new File(System.getProperty("java.home"), "bin"), windows ? "javaw.exe" : "java").getAbsolutePath(); + } + + private static String joinClasspath(List files) { + StringBuilder value = new StringBuilder(); + for (File file : files) { + if (value.length() > 0) value.append(File.pathSeparator); + value.append(file.getAbsolutePath()); + } + return value.toString(); + } + + private static final class ToolClasspath { + final List files; + ToolClasspath(List files) { this.files = files; } } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenGuiBuilderMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenGuiBuilderMojoTest.java new file mode 100644 index 00000000000..9f6fd9966be --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenGuiBuilderMojoTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.maven; + +import org.apache.maven.project.MavenProject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import static org.junit.Assert.*; + +public class OpenGuiBuilderMojoTest { + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void bindingIncludesProjectWideGuiAndCssLocations() throws Exception { + File common = tmp.newFolder("common"); + File input = tmp.newFile("guibuilder.input"); + File gui = new File(common, "src/main/guibuilder"); + File source = new File(common, "src/main/java"); + File css = new File(common, "src/main/css/theme.css"); + new OpenGuiBuilderMojo().writeBinding(input, common, gui, source, css); + String binding = new String(Files.readAllBytes(input.toPath()), StandardCharsets.UTF_8); + assertTrue(binding.contains("projectDir=" + common.getAbsolutePath())); + assertTrue(binding.contains("guiDir=" + gui.getAbsolutePath())); + assertTrue(binding.contains("cssFile=" + css.getAbsolutePath())); + } + + @Test + public void forwardsGuiBuilderPropertiesButNotTheBindingOrLaunchFlag() { + System.setProperty("guibuilder.mcp.port", "18349"); + System.setProperty("guibuilder.canvasMode", "desktop"); + System.setProperty("guibuilder.input", "/tmp/should-not-be-forwarded.input"); + System.setProperty("guibuilder.spawn", "false"); + try { + List args = new OpenGuiBuilderMojo().forwardedGuiBuilderProperties(); + assertTrue(args.contains("-Dguibuilder.mcp.port=18349")); + assertTrue(args.contains("-Dguibuilder.canvasMode=desktop")); + for (String arg : args) { + assertFalse(arg.startsWith("-Dguibuilder.input=")); + assertFalse(arg.startsWith("-Dguibuilder.spawn=")); + } + } finally { + System.clearProperty("guibuilder.mcp.port"); + System.clearProperty("guibuilder.canvasMode"); + System.clearProperty("guibuilder.input"); + System.clearProperty("guibuilder.spawn"); + } + } + + @Test + public void desktopIdentityOpensThePackagesTheJavaseRuntimeNeeds() { + List args = new OpenGuiBuilderMojo().desktopIdentityArgs(); + assertTrue(args.contains("-Dsun.awt.application.name=Codename One GUI Builder")); + // --add-exports is a Java 9 option. Passing it to an 8 JVM stops that JVM before it can + // print anything, and the editor is spawned detached, so the user would see nothing at all. + boolean modular = OpenGuiBuilderMojo.javaFeatureVersion() >= 9; + boolean exported = args.contains("--add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED") + && args.contains("--add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED"); + if (modular) { + assertTrue("a Java 9+ fork needs the desktop packages opened", exported); + } else { + assertFalse("a Java 8 fork rejects --add-exports and never starts", exported); + } + } + + @Test + public void detectsTheRunningJavaFeatureVersion() { + assertTrue("the plugin itself runs on JDK 8 or newer", + OpenGuiBuilderMojo.javaFeatureVersion() >= 8); + } + + @Test + public void launchesFromAggregatorOrCommonModule() throws Exception { + File root = tmp.newFolder("app"); + File common = new File(root, "common"); + assertTrue(common.mkdirs()); + Files.write(new File(root, "pom.xml").toPath(), "".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(common, "pom.xml").toPath(), "".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(common, "codenameone_settings.properties").toPath(), "codename1.packageName=com.example\n".getBytes(StandardCharsets.UTF_8)); + OpenGuiBuilderMojo mojo = new OpenGuiBuilderMojo(); + MavenProject project = new MavenProject(); + project.setFile(new File(root, "pom.xml")); + project.addCompileSourceRoot(new File(root, "src/main/java").getAbsolutePath()); + mojo.project = project; + assertTrue(mojo.isCN1ProjectDir()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/CodeEditorTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/CodeEditorTest.java index 95c24afbabf..6b01910646d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/CodeEditorTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/CodeEditorTest.java @@ -302,4 +302,50 @@ void testPureEditorUsedWithoutLowLevelTextInput() { assertTrue(editor.isEditorReady()); assertTrue(editor.getComponentAt(0) instanceof com.codename1.ui.editor.EditorView); } + + @FormTest + void testPureEditorProtectsGeneratedRegionsButAllowsUserRegions() { + implementation.setEditorNativePeerSupported(false); + String source = "class Form {\n" + + "// \n" + + "void generated() {}\n" + + "// \n" + + "void userCode() {}\n" + + "}"; + CodeEditor editor = new CodeEditor("java", source); + Form form = new Form("code", new BorderLayout()); + form.add(BorderLayout.CENTER, editor); + form.show(); + pump(); + + editor.setProtectedRegionMarkers( + "// ", "// "); + com.codename1.ui.editor.EditorView view = + (com.codename1.ui.editor.EditorView) editor.getComponentAt(0); + int generatedOffset = source.indexOf("generated()"); + view.moveCaret(generatedOffset, false); + view.insertText("blocked"); + assertEquals(source, view.getText(), "generated source must remain locked"); + + int userOffset = source.indexOf("userCode"); + view.moveCaret(userOffset, false); + view.insertText("custom"); + assertTrue(view.getText().contains("void customuserCode() {}"), + "the companion user-code region must remain editable"); + } + + @FormTest + void testSetCursorPositionMovesPureEditorCaret() { + implementation.setEditorNativePeerSupported(false); + CodeEditor editor = new CodeEditor("java", "0123456789"); + Form form = new Form("code", new BorderLayout()); + form.add(BorderLayout.CENTER, editor); + form.show(); + pump(); + + editor.setCursorPosition(7); + AtomicReference cursor = new AtomicReference(); + editor.getCursorPosition(cursor::set); + assertEquals(Integer.valueOf(7), cursor.get()); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SoftKeyCollisionTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SoftKeyCollisionTest.java new file mode 100644 index 00000000000..799431db376 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SoftKeyCollisionTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.ui; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.layouts.BorderLayout; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Key codes and character codes share one value space, so a port is free to map a soft key onto a + * value that is also a printable character. The desktop port maps the left soft key to VK_F1, which + * is 112 -- the character code of a lowercase 'p'. Before this was handled, a component editing text + * never received that character and one letter of the alphabet silently stopped working. + */ +class SoftKeyCollisionTest extends UITestBase { + + /** Records what actually reached the component, which is the only thing that matters here. */ + private static final class RecordingInput extends Component { + final StringBuilder typed = new StringBuilder(); + + boolean editsText = true; + + RecordingInput() { + setFocusable(true); + // Lists and editable sliders set this too, which is why it alone must not divert soft keys. + setHandlesInput(true); + } + + /** Declares that key codes reaching this component are text, exactly as the editors do. */ + @Override + protected boolean consumesRawTextInput() { + return editsText; + } + + @Override + public void keyReleased(int keyCode) { + typed.append((char) keyCode); + } + } + + private RecordingInput showFocusedInput() { + RecordingInput input = new RecordingInput(); + Form form = new Form("keys", new BorderLayout()); + form.add(BorderLayout.CENTER, input); + form.show(); + flushSerialCalls(); + form.setFocused(input); + return input; + } + + /// The desktop port maps the left soft key to VK_F1. The test implementation reports no soft + /// keys at all, so the collision is reproduced explicitly rather than inherited from it. + private static final int LOWERCASE_P = 'p'; + + @Test + void aFocusedInputReceivesCharactersThatCollideWithASoftKey() { + int previous = MenuBar.leftSK; + MenuBar.leftSK = LOWERCASE_P; + try { + RecordingInput input = showFocusedInput(); + + input.getComponentForm().keyReleased(LOWERCASE_P); + + assertEquals("p", input.typed.toString(), + "a component that handles its own input must receive a character whose code" + + " equals the left soft key, or that character can never be typed"); + } finally { + MenuBar.leftSK = previous; + } + } + + /** + * A port is free to use a negative code for Back. That code cannot stand for a character, so a + * focused text editor must not divert it: the form's back command, pop guard and + * minimize-on-back all hang off the menu bar seeing it, and the editor would discard it anyway. + */ + @Test + void aNegativeSoftKeyReachesTheMenuBarEvenWhileTextIsBeingEdited() { + int previous = MenuBar.leftSK; + MenuBar.leftSK = -11; + try { + RecordingInput input = showFocusedInput(); + assertTrue(input.editsText, "this test is about a component that does edit text"); + + input.getComponentForm().keyReleased(-11); + + assertEquals("", input.typed.toString(), + "a code that cannot be typed must stay with the menu bar even while editing"); + } finally { + MenuBar.leftSK = previous; + } + } + + @Test + void softKeysStillReachTheMenuBarWhenNothingIsEditing() { + int previous = MenuBar.leftSK; + MenuBar.leftSK = LOWERCASE_P; + try { + RecordingInput input = showFocusedInput(); + // Hand the keyboard back: the component no longer claims raw input. + input.setHandlesInput(false); + input.editsText = false; + + input.getComponentForm().keyReleased(LOWERCASE_P); + + assertEquals("", input.typed.toString(), + "soft key handling must still take precedence for components that do not edit text"); + } finally { + MenuBar.leftSK = previous; + } + } + + /** + * A list in single focus mode, an editable slider and a map all set handlesInput so the focus + * manager leaves their arrow keys alone. None of them turn key codes into characters, so a soft + * key must still reach the menu bar and fire its command while one of them holds the focus. + */ + @Test + void aComponentThatMerelyHandlesInputDoesNotSwallowSoftKeys() { + int previous = MenuBar.leftSK; + MenuBar.leftSK = LOWERCASE_P; + try { + RecordingInput input = showFocusedInput(); + input.editsText = false; + assertTrue(input.handlesInput(), "this is the case being guarded against"); + + input.getComponentForm().keyReleased(LOWERCASE_P); + + assertEquals("", input.typed.toString(), + "handlesInput alone must not divert a soft key away from the menu bar"); + } finally { + MenuBar.leftSK = previous; + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java index 0c46b00f92a..18bab3e2073 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java @@ -25,6 +25,7 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.Display; import com.codename1.ui.Form; import com.codename1.ui.TextInputClient; import com.codename1.ui.TextInputConfig; @@ -132,6 +133,49 @@ void readOnlyEditorDoesNotBindASession() { assertEquals(1, host.stops); } + @FormTest + void theEditorOnlySwitchesMultiKeyModeOffWhenItSwitchedItOn() { + boolean wasMultiKey = Display.getInstance().isMultiKeyMode(); + try { + Display.getInstance().setMultiKeyMode(false); + CountingHost host = new CountingHost(); + EditorView v = show(host, "hello"); + v.requestFocus(); + flushSerialCalls(); + assertTrue(Display.getInstance().isMultiKeyMode(), + "the editor turns multi key mode on so fast typing does not drop key releases"); + + form.getContentPane().removeComponent(v); + flushSerialCalls(); + assertFalse(Display.getInstance().isMultiKeyMode(), + "the editor that turned the mode on turns it back off"); + } finally { + Display.getInstance().setMultiKeyMode(wasMultiKey); + } + } + + @FormTest + void anApplicationThatOwnsMultiKeyModeKeepsItAfterTheEditorGoesAway() { + boolean wasMultiKey = Display.getInstance().isMultiKeyMode(); + try { + // the application asked for multi key mode itself; the editor is a guest here and must + // leave the global setting exactly as it found it + Display.getInstance().setMultiKeyMode(true); + CountingHost host = new CountingHost(); + EditorView v = show(host, "hello"); + v.requestFocus(); + flushSerialCalls(); + assertTrue(Display.getInstance().isMultiKeyMode()); + + form.getContentPane().removeComponent(v); + flushSerialCalls(); + assertTrue(Display.getInstance().isMultiKeyMode(), + "an editor that found multi key mode on must not switch it off"); + } finally { + Display.getInstance().setMultiKeyMode(wasMultiKey); + } + } + @FormTest void finalizedCompositionUndoesAsASingleUnit() { CountingHost host = new CountingHost(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/layouts/LayeredLayoutTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/layouts/LayeredLayoutTest.java index d4a01890af5..708a1486390 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/layouts/LayeredLayoutTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/layouts/LayeredLayoutTest.java @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + package com.codename1.ui.layouts; import com.codename1.junit.FormTest; @@ -282,6 +305,29 @@ void testUnitConstants() { assertEquals(LayeredLayout.UNIT_BASELINE, LayeredLayout.UNIT_BASELINE); } + @FormTest + void baselineReferenceUsesActualComponentBaselinesWithDifferentMarginsAndPadding() { + LayeredLayout layout = new LayeredLayout(); + Container container = new Container(layout); + container.setSize(new Dimension(500, 240)); + Label label = new Label("Baseline label"); + Button button = new Button("Baseline button"); + label.getAllStyles().setPadding(4, 6, 3, 3); + label.getAllStyles().setMargin(0, 0, 0, 0); + button.getAllStyles().setPadding(12, 10, 8, 8); + button.getAllStyles().setMargin(10, 5, 4, 4); + container.add(label).add(button); + layout.setInsets(label, "30px auto auto 20px"); + layout.setInsets(button, "baseline auto auto 180px"); + layout.setReferenceComponentTop(button, label, 0); + + container.layoutContainer(); + + assertEquals(label.getY() + label.getBaseline(label.getWidth(), label.getHeight()), + button.getY() + button.getBaseline(button.getWidth(), button.getHeight()), + "baseline unit must align the actual rendered text, including themed margins and padding"); + } + @FormTest void testLayeredLayoutWithVariedSizes() { LayeredLayout layout = new LayeredLayout(); diff --git a/maven/update-version.sh b/maven/update-version.sh index 22f1e84f2b3..8383f3a79bf 100755 --- a/maven/update-version.sh +++ b/maven/update-version.sh @@ -114,6 +114,19 @@ if [ -f "$settingsParent" ]; then perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$settingsParent" fi +# GUI Builder editor is a standalone Maven-first desktop app distributed alongside +# the plugin, matching Settings and Game Builder. +for f in ../scripts/guibuilder/pom.xml \ + ../scripts/guibuilder/common/pom.xml \ + ../scripts/guibuilder/javase/pom.xml; do + [ -f "$f" ] && perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$f" +done +guiBuilderParent=../scripts/guibuilder/pom.xml +if [ -f "$guiBuilderParent" ]; then + perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$guiBuilderParent" + perl -pi -e "s{\Q$oldVersion\E}{$version}g" "$guiBuilderParent" +fi + echo "Committing version change in git" git add -u . # Note: the -u is to prevent adding files that aren't added to git yet. Only changed @@ -121,6 +134,7 @@ git add -u . git add -u ../scripts/gamebuilder git add -u ../scripts/certificatewizard git add -u ../scripts/settings +git add -u ../scripts/guibuilder git commit -m "Updated version to $version" if [[ "$version" == *-SNAPSHOT ]]; then echo "This is a snapshot version so not adding a tag" diff --git a/scripts/guibuilder/.gitignore b/scripts/guibuilder/.gitignore new file mode 100644 index 00000000000..16b737a5787 --- /dev/null +++ b/scripts/guibuilder/.gitignore @@ -0,0 +1,7 @@ +target/ +common/target/ +javase/target/ + +# Written by cn1:guibuilder (and by hand for the demo project); it holds +# absolute paths for one machine, so it is generated rather than tracked. +demo-project/guibuilder.input diff --git a/scripts/guibuilder/STATUS.md b/scripts/guibuilder/STATUS.md new file mode 100644 index 00000000000..475e35273d2 --- /dev/null +++ b/scripts/guibuilder/STATUS.md @@ -0,0 +1,1170 @@ +# Modern GUI Builder status + +- Last updated: 2026-08-05 +- Branch: `feat-guibuilder-rewrite` +- Working state: committed and rebased on `origin/master` + +## Executive summary + +The old settings-era GUI Builder has been replaced locally with a Maven-first, +standalone Codename One desktop application under `scripts/guibuilder`. The new +builder opens an entire Maven project rather than one form at a time, discovers +all `.gui` files recursively, switches between them, renders a live preview, +edits the XML model, generates Java and binding code, edits `theme.css`, exposes +accessibility semantics, and can be inspected or driven over MCP. + +Guided Layout is the default for newly created containers. It is implemented on +top of `LayeredLayout` with persistent name-based relationships, responsive +insets, reference positions, size policies, baseline alignment, live simulated +drag/resize results, dependency visualization, detach behavior, multi-selection, +group movement, and group resizing. + +The latest `origin/master` was fetched and fast-forwarded into this working tree +on 2026-07-28. The previous base was 33 commits behind. There were no overlapping +paths between the upstream changes and this GUI Builder work, so the update was a +clean fast-forward. + +Current validation after that update and the 2026-07-28 review pass: + +- 18 `CodeEditorTest` tests pass. +- 23 `LayeredLayoutTest` tests pass. +- 41 focused core tests pass in total. +- All 66 standalone GUI Builder tests pass (63 previous plus 3 new model tests). +- All 5 `OpenGuiBuilderMojoTest` tests pass (2 previous plus 3 new launcher tests). +- Both `git diff --check` and `git diff --cached --check` pass. + +Important: the standalone suite only compiles after the current core is +reinstalled into the local repository it builds against. Running it against a +stale `8.0-SNAPSHOT` fails with `cannot find symbol +setProtectedRegionMarkers/setCursorPosition`, because the editor depends on +core APIs that are part of this same change. See +[Install current snapshots for the standalone build](#install-current-snapshots-for-the-standalone-build). + +The implementation is substantially functional and test-backed, but it is still +a large uncommitted change. It should not be treated as shipped until it is +reviewed, staged intentionally, committed on a feature branch, and validated in +CI and through another deliberate hands-on GUI session. + +## Original requirements and product direction + +The work is based on these requirements: + +- Replace the GUI Builder bundled with the old Codename One Settings. +- Package and distribute it like the standalone Maven-based Settings tool. +- Preserve the useful architecture of the old builder without retaining Ant-era + assumptions or the old downloaded `guibuilder.jar` mechanism. +- Use modern Codename One UI constructs, menus, CSS, accessibility, and MCP. +- Detect all GUI Builder files in a project and switch between forms quickly. +- Prefer CSS and UIIDs over resource-file authoring. +- Provide source and event editing inside the application. +- Make Guided Layout the default and strong enough to construct arbitrary, + responsive user interfaces. +- Support predictable drag/drop, live placement previews, resizing, alignment, + baseline relationships, size policies, detach behavior, multi-selection, and + atomic undo/redo. + +The historical design reference is +`codenameone/CodenameOne#3175`. The legacy implementation used for behavioral +reference is the `GUIBuilder` module of the pre-Maven CodenameOne checkout, which +is not part of this repository. + +## Repository and cleanup state + +### Latest master + +The branch is now exactly at: + +```text +c1e839071d83ed84b79dae527ad738a8061e0b83 +Let the grace passes mark objects the resolve guard was discarding (issue 5425) (#5477) +``` + +The update from `4c4fc3e327` to `c1e839071d` was a 33-commit fast-forward. +Upstream did not add or modify `scripts/guibuilder` or any other path that +collided with the local GUI Builder changes. + +### Cleanup performed + +The following unrelated or temporary material was discarded: + +- `docs/website/static/social/` — untracked social artwork unrelated to the + GUI Builder. +- `scripts/hellocodenameone/common/iosCerts/` — untracked local signing + certificates and provisioning profiles. +- `scripts/hellocodenameone/common/androidCerts/` — ignored local Android + keystore material. +- `scripts/hellocodenameone/common/target/` — unrelated ignored build output. +- Local signing paths and passwords in + `scripts/hellocodenameone/common/codenameone_settings.properties` were + restored to `HEAD`. +- The accidental stash was dropped after confirming that intended changes were + already recovered. Its only non-recovered changes were the obsolete + `CodeEditorHtml` and JavaSE Swing-editor path that were deliberately replaced + by master’s pure Codename One editor. + +No stash is required to recover the current GUI Builder work. The working tree +itself is now the authoritative local copy. + +### Important staging warning + +The worktree contains a mixture of staged, unstaged, and untracked changes +because it was recovered from an accidental stash and then adapted to master. +Most of `scripts/guibuilder` is still untracked. Before committing, inspect +`git status`, stage only the paths listed in this document, and do not use a +broad commit that could pick up unrelated files. + +## Module and file map + +### Standalone application + +- `scripts/guibuilder/pom.xml` + - Maven reactor root. + - Java 8 source/target. + - Modules for common and JavaSE code. + - `guibuilder-central` release profile with sources, Javadocs, GPG signing, + and Central publishing. +- `scripts/guibuilder/common/pom.xml` + - Codename One common application module. + - Compiles CSS and annotations through the Codename One Maven plugin. + - Attaches common tests for the JavaSE test module. +- `scripts/guibuilder/javase/pom.xml` + - Desktop executable artifact: + `com.codenameone:codenameone-guibuilder`. + - `executable-jar` profile builds the launcher JAR and copies runtime + dependencies into `target/libs`. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java` + - Main application, workspace, menus, inspector, drag/drop, resize, + source/CSS editing, generation, undo/redo, accessibility state, and MCP + domain operations. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/GuiBuilderMcpController.java` + - GUI Builder-specific MCP tools and live action journal. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/model/GuiDocument.java` + - XML model, normalized attributes, selection, clipboard operations, + hierarchy changes, toolbar commands, transactions, undo/redo, and + serialization. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java` + - Parses the project-binding input file. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java` + - Portable file access, recursive `.gui` discovery, and UTF-8 reads/writes. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/ComponentPreviewFactory.java` + - Converts XML elements to live Codename One components. + - Applies component properties, layout constraints, stable accessibility + identifiers, design-only event behavior, and safe preferred-size overrides. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/DragGuideOverlay.java` + - Selection outlines, visible resize handles, drag ghosts, snap guides, + dependency arrows, affected-component rectangles, and descriptive tags. +- `scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/GuidedLayoutSupport.java` + - Persistent name-based Guided Layout constraints and size policies. +- `scripts/guibuilder/common/src/main/css/theme.css` + - Builder UI theme, including light/dark styles, toolbars, inspector, + sidebars, editors, selection controls, and canvas skins. +- `scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderLauncher.java` + - Desktop entry point. +- `scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderStub.java` + - Generated-style JavaSE lifecycle wrapper. +- `scripts/guibuilder/tools/guibuilder-mcp-client.mjs` + - Small JSON-line MCP client used to inspect and drive the live builder. + +### Demo project + +`scripts/guibuilder/demo-project` is the reproducible manual-test project. It +contains: + +- `GuidedLayoutForm.gui` +- `BorderDropForm.gui` +- `BoxXLayoutForm.gui` +- `GridLayoutForm.gui` +- `TableLayoutForm.gui` +- `NestedLayoutsForm.gui` +- `LoginForm.gui` +- `theme.css` +- `guibuilder.input` + +The input file binds: + +```text +projectDir= +guiDir=/src/main/guibuilder +sourceDir=/src/main/java +cssFile=/src/main/css/theme.css +initialForm=com.example.GuidedLayoutForm +``` + +## Maven distribution and launcher integration + +`maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenGuiBuilderMojo.java` +was rewritten to launch the standalone Maven artifact instead of downloading or +opening the old `~/.codenameone/guibuilder.jar`. + +Current behavior: + +- `mvn cn1:guibuilder` can run from the application aggregator or `common` + module. +- `-DclassName=com.example.FormName` optionally selects the initial form. +- The mojo creates a unique binding file under + `~/.codenameoneGUIBuilder/`. +- The binding covers the whole project: project directory, GUI directory, + source directory, CSS file, and optional initial form. +- The launcher resolves `com.codenameone:codenameone-guibuilder` and its + transitive runtime dependencies through Maven. +- It normally spawns a detached process and writes output to + `~/.codenameoneGUIBuilder/guibuilder.log`. +- `-Dguibuilder.spawn=false` or the legacy `-Dspawn=false` path can run it in + the foreground. +- A JVM property prevents duplicate launches within one Maven invocation. +- Every other `guibuilder.*` system property is forwarded to the editor JVM, so + `mvn cn1:guibuilder -Dguibuilder.mcp.port=18349` (and `darkMode`, + `canvasMode`, `initialSelection`, `openEditor`) reach the application. The + `guibuilder.input` binding and the `guibuilder.spawn` flag are owned by the + mojo and are never forwarded. +- The process is named for the dock, taskbar, and window manager, and the + `java.desktop/com.apple.eawt*` packages the JavaSE port needs are exported, + matching `cn1:settings` and `cn1:certificate-wizard`. +- The goal fails with a friendly message when Maven runs on a JDK older than + 8. The editor is a Java 8 artifact and the spawned process writes only to + `guibuilder.log`, so without the check an old JDK produces a silent + `UnsupportedClassVersionError`. `--add-exports` is only passed to a JDK 9 or + newer fork; on 8 it is an unrecognized option that stops the JVM outright. +- No Ant GUI Builder protocol, running marker file, resource-file handoff, or + IDE source-jump callback remains. + +Release integration is present in: + +- `.github/workflows/release-on-maven-central.yml` +- `maven/update-version.sh` + +The release workflow deploys the executable JavaSE artifact after the main +Codename One deployment and polls Maven Central for it. The version update +script updates all three GUI Builder POMs and their Codename One/plugin +properties. + +## Workspace and general UX + +The builder uses a resizable three-area workspace: + +- Left: project forms/hierarchy tabs plus the searchable component palette. +- Center: canvas-mode toolbar and live design surface. +- Right: Properties, Layout, and Events inspector tabs. + +Nested `SplitPane` components make both sidebar boundaries resizable. Defensive +null guards were added to `SplitPane` and `Tabs` because the builder rebuilds +parts of the inspector and tab hierarchy during active pointer gestures. + +The main toolbar exposes: + +- Save +- Undo +- Redo +- Refresh +- CSS editor +- Companion Java editor + +The canvas toolbar exposes: + +- Phone portrait +- Phone landscape +- Tablet portrait +- Desktop full-canvas mode + +Desktop mode has no device skin and fills the available center area. Phone and +tablet modes use predictable fixed design surfaces. Dark mode is persisted in +preferences and can be toggled through the application command path and MCP. + +All project forms are discovered recursively beneath `guiDir`, sorted, shown +with simple names and fully qualified tooltips, and opened as independent +`GuiDocument` instances. Unsaved work is never silently discarded when +switching forms. + +The component palette currently includes: + +- Button +- Label +- SpanLabel +- TextField +- TextArea +- CheckBox +- RadioButton +- Slider +- Container +- Tabs + +Palette items can be activated or dragged. Palette search filters immediately. + +## Component model and property editing + +`GuiDocument` preserves unknown XML while normalizing edited attributes +case-insensitively. It supports: + +- Unique component names. +- Add, delete, copy, cut, paste, reorder, and cross-container moves. +- Cycle prevention when moving containers in the hierarchy. +- Compound transactions so one placement/group action is one undo entry. +- Toolbar commands stored separately from visual components. +- BorderLayout constraint normalization so duplicate or missing constraints do + not hide components. +- Default UIIDs derived from component type when an explicit UIID is absent. +- Guided Layout as the default for new child-bearing components. + +The Properties inspector supports type-aware fields, including: + +- Name +- Form title +- Text +- Hint +- UIID/CSS selector +- Enabled, visible, RTL +- Alignment, gap, ticker +- Button toggle state +- CheckBox/RadioButton selected state +- Text-area columns, rows, maximum length, editable/grow settings, constraints +- Slider range/progress/editability +- Tabs selected index and placement +- Container horizontal/vertical scrolling + +Numeric layout fields use bounded parsing and reject invalid text and overflow +instead of writing malformed values into the document. + +Editing a text-bearing component or form title can be started through the +inspector, long press, or double click. The inline `TextField` is an overlay, +not a child inserted into the designed layout. Clicking outside commits and +tears it down; the XML property and visible preview update together. + +Form toolbar commands can be added, removed, renamed, assigned to left/right/ +overflow/side placement, and connected to generated event handlers. + +## Layout adapter architecture + +Every supported layout is routed through a placement adapter: + +- `LayeredPlacementAdapter` +- `BorderPlacementAdapter` +- `BoxPlacementAdapter` +- `FlowPlacementAdapter` +- `GridPlacementAdapter` +- `TablePlacementAdapter` + +This separation is important. A generic component-reorder algorithm cannot +correctly represent BorderLayout slots, TableLayout cells, Guided Layout +relationships, or BoxLayout insertion gaps. + +### BorderLayout + +- Edge bands are resolved before component hit testing so Center cannot consume + all reachable East/West/North/South positions. +- The band size scales with the dragged component while remaining usable with a + mouse. +- Dropping onto an occupied slot swaps components instead of stacking one + behind the other. +- Missing and duplicate legacy constraints are normalized deterministically. +- The designer caps edge-component preferred dimensions to keep a displaced + component from consuming the whole preview. +- Tests verify that a West-to-Center move remains visible, serializes, and + renders after reopening. + +### BoxLayout and FlowLayout + +- A real temporary spacer opens at the exact insertion point. +- X and Y axes calculate before/after using the correct dimension. +- The spacer is removed on cancellation and completion. +- Reordering updates both the XML and rendered order. +- Scrollable previews disable tensile/bounce behavior. +- Edge dragging can advance a scrollable horizontal container without the + native tensile animation fighting the drag. + +### GridLayout + +- Drops reorder children into deterministic grid order. +- Cells remain unique and components are not overlaid. +- Row/column values are validated. + +### TableLayout + +- Drops assign unique cells. +- Rows expand when necessary rather than hiding overflow. +- Reorder commands reassign cells and visibly change order. +- Row, column, spans, and optional percentage sizes are editable. + +### Nested containers and form isolation + +- Hit testing finds the deepest valid container. +- Moving across nested containers updates only the selected branch. +- A container cannot be moved into its own descendant. +- Stale elements from a previously opened form are rejected because every drag + retains and validates its originating `GuiDocument`. + +## Guided Layout + +Guided Layout is represented by `LayeredLayout` plus builder-owned, +name-based metadata. Names are used because serialized component indexes become +unstable when siblings are reordered. + +Per-component data includes: + +- `layeredInsets` +- `guidedReferences` +- `guidedReferencePositions` +- horizontal and vertical anchors +- horizontal and vertical size policies +- optional matched-width/matched-height targets +- fixed preferred width/height values when explicitly resized + +Supported size policies on each axis: + +- Preferred +- Fixed +- Fill parent +- Match reference + +Supported snap/alignment relationships: + +- Left edge +- Horizontal center +- Right edge +- Top edge +- Text baseline +- Bottom edge +- Same width +- Same height +- Fill width +- Fill height + +The `LayeredLayout` baseline implementation was corrected to use actual +component baselines, margins, and padding for components that declare a +baseline. `Component.getBaseline()` has a non-abstract default that returns the +bottom content edge rather than a text baseline, so the layout only trusts it +for components that also describe their baseline resize behavior (`Label` and +its subclasses: `Button`, `CheckBox`, `RadioButton`). Everything else +(`Container`, `TextArea`, `TextField`) keeps the historical font-ascent +approximation, so no existing layout changes behavior. See +[Known limitations](#known-limitations-and-remaining-work) for the follow-up: +`TextArea` should report a real text baseline. + +### Drag behavior + +- The initial pointer grab offset is preserved, so a component does not jump + when dragging starts. +- Selection does not modify component styles or layout metrics. +- A live simulation clones the document and lays out the proposed result. +- The overlay shows the dragged ghost, every affected rectangle, snap lines, + dependency arrows, and concise movement/size deltas. +- The simulated result and committed result are tested to match. +- Dependencies are visualized before commit so cascading effects are visible. +- Moving into free space tears away incoming relationships and persists fixed + insets. +- Snapping back to the same reference preserves the explicit relationship. +- Cycle-breaking rebases or freezes only the relationship that would create the + cycle, preventing the rest of the layout from bouncing or disappearing. +- Transitive dependents remain visible when the primary/secondary sample + components are moved around each other. + +### Resizing + +- Edge and corner hit zones are larger than the painted handles. +- Cursor shapes change to the relevant horizontal, vertical, or diagonal resize + cursor. +- The filled handles identify the stable reference component. +- Live resize simulation shows dependent cascade without mutating the model. +- Resizing the reference component can resize all selected components in + unison. +- Same-width/same-height uses the primary selected component as the reference; + other components stretch to it instead of all components shrinking to an + arbitrary minimum. +- Fixed guided dimensions are implemented through `calcPreferredSize()` + overrides in generated/preview components. Deprecated + `setPreferredW()`, `setPreferredH()`, or similar APIs are not used. + +### Multi-selection + +- Shift, Control, or Command click toggles additive selection. +- A normal click selects a new component without requiring an initial drag. +- Every selected component receives its own outline and handles. +- The first/primary selected component remains the reference and uses filled, + larger handles. +- Group drag preserves relative positions and internal relationships. +- Group drag and group resize commit atomically and undo in one step. +- The floating icon toolbar is shown only for meaningful multi-selection in one + Guided Layout parent. +- Every icon has a tooltip and accessibility label explaining that the + filled-handle component is the reference. +- Actions cover left/center/right, top/baseline/bottom, same width, same + height, and disconnect. + +## Undo/redo + +Structural and layout changes are stored in `GuiDocument` snapshots. Compound +operations use explicit transactions, making a group move, group resize, slot +swap, or relationship action one undo step. + +The embedded pure `CodeEditor` has its own undo/redo stack. CSS and Java editing +remain inside the editor rather than routing Command-Z to close the editor. + +Form-model undo/redo and source-editor undo/redo are separate domains. A future +polish pass may expose which domain currently owns the global shortcut more +explicitly. + +## CSS and theme editing + +The CSS button opens the project `theme.css` in the built-in Codename One +`CodeEditor`, not a native external application or CEF-based editor. + +Current behavior: + +- Editable pure Codename One source surface. +- CSS syntax highlighting and editor gutter come from the modern `CodeEditor`. +- A short debounced live-edit timer reads the editor text. +- `CSSThemeCompiler` compiles into an in-memory `MutableResource`. +- The compiled project theme is applied to the preview immediately. +- The builder theme is restored after preview styling, preventing the builder + chrome from inheriting arbitrary project styles. +- Compile failures produce editor diagnostics and leave the last valid preview + intact. +- Saving writes UTF-8 back to the project CSS file. +- Font and box-unit values from the runtime compiler are normalized for safe + preview use. + +The project preview is CSS-driven; the standalone builder still has a compiled +`theme.res` generated from its own `theme.css` as a normal Codename One build +artifact. This is not a return to resource-file authoring. + +## Java source, events, and binding + +The Code toolbar opens an editable, live-generated companion source preview +inside the builder. + +Generated source contains explicit markers: + +```java +// +// +// +// +``` + +The generated regions are protected in the editor while the user-code region +remains editable. Regeneration merges the existing user-code region instead of +deleting it. The editor can move the caret directly to a generated event +handler. + +Generated code includes: + +- Form title and layout. +- Component fields and construction. +- Names and UIIDs. +- BorderLayout and TableLayout constraints. +- Guided Layout insets, name-resolved references, reference positions, and + anchors. +- Toolbar commands. +- Component and command action handlers. + +Binding strategy is selectable per form: + +- None +- `PropertyBusinessObject` +- `@Bindable` POJO using build-time binding annotations + +Generated-source tests compile the form and model together for all three +strategies. No-binding output has no model dependency. + +## Accessibility + +The builder uses the accessibility APIs added on master: + +- Stable identifiers for workspace, forms list, hierarchy tree, palette, + inspector, status, canvas modes, selection actions, and preview components. +- Roles for lists, tree, search field, and generic container groups. +- Labels, descriptions, hints, selected state, pane titles, and grouping. +- A polite live region for status updates. + +Preview component identifiers follow: + +```text +guibuilder.preview. +``` + +These identifiers are also published in MCP state, allowing tests and agents to +correlate XML elements, physical bounds, and accessibility nodes. + +## MCP support and live inspection + +The builder registers the portable UI accessibility MCP tools provided by +Codename One and adds domain-specific tools: + +- `guibuilder_state` + - Active form and path + - All form names + - Canvas mode and bounds + - Dark mode + - Modified/undo/redo state + - Selected component(s) + - Selection paint bounds + - Component attributes, layouts, bounds, visibility, and accessibility IDs + - Current drop/resize guide +- `guibuilder_actions` + - Sequence-numbered journal + - Optional long poll up to ten seconds + - Bounded history of 500 actions +- `guibuilder_select` + - Normal or additive selection by component name +- `guibuilder_open_form` + - Safe form switching that refuses to discard unsaved work +- `guibuilder_drag` + - Drives the real pointer drag path by absolute coordinates or semantic + target/placement +- `guibuilder_command` + - Save, undo, redo, refresh, dark mode, and canvas-mode commands + +The socket is opt-in through: + +```text +-Dguibuilder.mcp.port=18349 +``` + +The verified endpoint is loopback: + +```text +127.0.0.1:18349 +``` + +The MCP controller crosses an EDT barrier after commands before returning +state, preventing new component bounds from being paired with stale selection +bounds. + +## Tests + +### Standalone GUI Builder: 63 tests + +`DesignerInteractionTest` has 41 tests covering: + +- Fixed width without freezing theme-derived height +- Exact BoxLayout spacer targets +- Selection without drag +- modifier-based multi-selection +- reference-based same width +- atomic group movement and undo +- group ghost/commit equivalence +- internal group relationship preservation +- guided rectangle persistence +- pointer grab offsets +- responsive surface-edge docking +- free-space detach +- explicit relationship preservation +- cycle prevention and downstream visibility +- actual baseline alignment +- center anchors +- selection/layout metric stability +- resize snapping +- group resize +- resize simulation +- drag simulation +- BorderLayout swap/serialization +- inline editor teardown +- explicit placement adapter routing +- reachable BorderLayout edges +- numeric validation +- BoxLayout X +- GridLayout +- TableLayout placement/reorder +- nested placement +- cross-form stale-element rejection +- horizontal scrolling and drag autoscroll +- accessibility identifiers +- MCP state, action journal, and additive selection + +Additional standalone tests: + +- `GuiDocumentTest`: 18, including the three added by the review pass: + - a pasted container renames its children and keeps its internal + relationships pointed at the copy, + - renaming keeps names unique and repoints every relationship in one undo + step, + - deleting a referenced component leaves no dangling relationship. +- `GeneratedSourceTest`: 4 +- `CodeEditorInteractionTest`: 2 +- `ProjectBindingTest`: 1 + +Latest result: + +```text +Tests run: 66, Failures: 0, Errors: 0, Skipped: 0 +``` + +### Core tests: 41 + +Latest focused result: + +```text +CodeEditorTest: 18 passed +LayeredLayoutTest: 23 passed +Total: 41 passed +``` + +The two new editor tests verify: + +- Protected generated regions reject user edits while the user-code region + remains editable. +- `setCursorPosition()` moves the pure editor caret. + +The LayeredLayout regression verifies actual baseline alignment across +different component margins and padding. + +### Maven launcher tests: 5 + +`OpenGuiBuilderMojoTest` verifies: + +- Binding files contain project-wide GUI, source, CSS, and initial-form data. +- The goal launches correctly from either the application aggregator or common + module without duplicate launches. +- `guibuilder.*` properties are forwarded while the binding and spawn flag are + not. +- The desktop identity arguments export the JDK packages the JavaSE runtime + needs. +- The running Java feature version is detected (the input to the JDK 8 gate). + +Latest result: + +```text +Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 +``` + +### Non-failing test noise + +- The JavaSE interaction suite prints many `EDT violation detected!` diagnostic + lines. The assertions pass, but this noise should eventually be investigated + or suppressed by making the tests consistently cross the EDT. +- The plugin test prints an HTML tidy warning: + `line 23 column 45 - Error: is not recognized!` + It is non-fatal and unrelated to the GUI Builder assertions. +- The supplied `-Plocal-dev-javase` core-test profile is not defined at the + top-level Maven reactor in this checkout; Maven warns and continues. The + focused tests still compile and pass with Java 8. + +## Build and test commands + +### Core regression tests + +Run with Java 8: + +```bash +cd maven +export JAVA_HOME="$(/usr/libexec/java_home -v 1.8)" # any JDK 8 +export PATH="$JAVA_HOME/bin:$PATH" +mvn -pl core-unittests -am \ + -DunitTests=true \ + -Dmaven.javadoc.skip=true \ + -Plocal-dev-javase \ + -Dcn1.binaries="$PWD/target/cn1-binaries" \ + -Dtest=CodeEditorTest,LayeredLayoutTest \ + -Dsurefire.failIfNoSpecifiedTests=false \ + test +``` + +### Install current snapshots for the standalone build + +The isolated repository used during development is +`/tmp/cn1-local-repo`. Reinstall current core, CSS compiler, JavaSE, and Maven +plugin artifacts after changing branches or updating master. Otherwise Maven +can silently test against an older `8.0-SNAPSHOT`. + +This is a prerequisite, not an optimization. The editor calls +`CodeEditor.setProtectedRegionMarkers()` and `setCursorPosition()`, which are +added by this change, so against an older snapshot the standalone build fails +with `cannot find symbol` before any test runs. Reinstalling `core` alone is +enough after editing only `CodenameOne/src`. + +JavaSE must be built with `cn1.binaries` so `jfxrt.jar` is available: + +```bash +cd maven +mvn -Dmaven.repo.local=/tmp/cn1-local-repo \ + -pl factory,core,css-compiler \ + -DskipTests -Dmaven.javadoc.skip=true install + +mvn -Dcn1.binaries="$PWD/target/cn1-binaries" \ + -Dmaven.repo.local=/tmp/cn1-local-repo \ + -pl javase \ + -DskipTests -Dmaven.javadoc.skip=true \ + clean install + +mvn -Dmaven.repo.local=/tmp/cn1-local-repo \ + -pl codenameone-maven-plugin \ + -DskipTests -Dmaven.javadoc.skip=true install +``` + +### Full GUI Builder suite + +Run with Java 8: the standalone project targets Java 8 so that the editor loads +on whichever JDK a Codename One project is already built with, and only a JDK 8 +compiler proves that no newer API crept in. + +```bash +cd scripts/guibuilder +export JAVA_HOME="$(/usr/libexec/java_home -v 1.8)" # JDK 8 +export PATH="$JAVA_HOME/bin:$PATH" +mvn -nsu \ + -Dmaven.repo.local=/tmp/cn1-local-repo \ + -pl javase -am clean test +``` + +On macOS the CSS compiler may exit with code 134 inside the filesystem/process +sandbox. Run the same Maven command with normal desktop/native process access; +this is a process-environment issue, not a CSS syntax failure. + +### Maven launcher test + +```bash +cd maven +mvn -nsu \ + -Dmaven.repo.local=/tmp/cn1-local-repo \ + -pl codenameone-maven-plugin \ + -Dtest=OpenGuiBuilderMojoTest \ + test +``` + +### Package executable + +```bash +cd scripts/guibuilder +mvn -nsu \ + -Dmaven.repo.local=/tmp/cn1-local-repo \ + -pl javase -am \ + -Pexecutable-jar \ + -DskipTests package +``` + +Expected main artifact: + +```text +scripts/guibuilder/javase/target/codenameone-guibuilder-8.0-SNAPSHOT.jar +``` + +### Run the demo with MCP + +The binding file holds absolute paths, so it is generated per machine rather +than tracked (see `scripts/guibuilder/.gitignore`). `mvn cn1:guibuilder` writes +one for a real project; for the demo project, write it once: + +```bash +cd scripts/guibuilder/demo-project +cat > guibuilder.input <&2; exit 2 ;; + esac +done + +# Asserts a class inside the installed core jar contains a symbol the working tree defines. javap on +# the installed artifact is the only check that cannot be satisfied by a stale build. +verify_core_class() { + local class_path="$1" symbol="$2" + local dir extracted + dir="$(mktemp -d)" + extracted="$dir/$(basename "$class_path")" + unzip -o -p "$CORE_JAR" "$class_path" > "$extracted" + if ! javap -p "$extracted" | grep -q "$symbol"; then + echo "STALE ARTIFACT: $CORE_JAR is missing '$symbol' from $class_path." >&2 + echo "The install reported success but packaged an older class. Re-run with a clean target." >&2 + rm -rf "$dir" + exit 1 + fi + rm -rf "$dir" +} + +echo "==> core (JDK 8)" +cd "$REPO_ROOT/maven" +# mvn clean is not enough: the core module has produced a jar mixing freshly compiled classes with +# stale ones, which is how fixes kept reaching the tests but not the running editor. Remove the +# output directory outright. +rm -rf core/target factory/target css-compiler/target +# Offline: an online build resolves codenameone-core / codenameone-javase from the remote snapshot +# repository and installs it over the jar just built here, which is how the editor kept running +# without changes that had demonstrably compiled. +JAVA_HOME="$JDK8" PATH="$JDK8/bin:$PATH" \ + mvn -q -o -Dmaven.repo.local="$LOCAL_REPO" -pl factory,core,css-compiler \ + -DskipTests -Dmaven.javadoc.skip=true clean install +# mvn install has repeatedly reported success while leaving the previous jar in place, so the +# freshly built artifacts are copied over it explicitly. Trusting install is what made fixes compile +# and pass tests without ever reaching the running editor. +for module in core factory css-compiler; do + for jar in "$REPO_ROOT/maven/$module/target/"*.jar; do + [ -e "$jar" ] || continue + case "$jar" in *-sources.jar|*-javadoc.jar) continue;; esac + artifact="$(basename "$jar" | sed 's/-8\.0-SNAPSHOT\.jar$//')" + dest="$LOCAL_REPO/com/codenameone/$artifact/8.0-SNAPSHOT/$(basename "$jar")" + [ -e "$dest" ] && cp -f "$jar" "$dest" + done +done + +JAVA_HOME="$JDK8" verify_core_class "com/codename1/ui/editor/CodeView.class" "protectedStartMarker" +JAVA_HOME="$JDK8" verify_core_class "com/codename1/ui/editor/EditorView.class" "multiKeyModeInstalled" +JAVA_HOME="$JDK8" verify_core_class "com/codename1/ui/CodeEditor.class" "setCursorPosition" +JAVA_HOME="$JDK8" verify_core_class "com/codename1/ui/Form.class" "focusedHandlesInput" +JAVA_HOME="$JDK8" verify_core_class "com/codename1/ui/editor/EditorView.class" "copySelection" + +echo "==> javase port (JDK 8)" +JAVA_HOME="$JDK8" PATH="$JDK8/bin:$PATH" \ + mvn -q -o -Dcn1.binaries="$REPO_ROOT/maven/target/cn1-binaries" -Dmaven.repo.local="$LOCAL_REPO" \ + -pl javase -DskipTests -Dmaven.javadoc.skip=true clean install +# The port is copied over the installed artifact for the same reason core is, with one addition: +# an unrelated build in this repository has resolved codenameone-javase from a remote snapshot and +# installed it over the local one, after which Display.init returns a null implementation and every +# test in the suite fails at once. Run other Maven commands against this repository offline (-o). +for jar in "$REPO_ROOT/maven/javase/target/"*.jar; do + [ -e "$jar" ] || continue + case "$jar" in *-sources.jar|*-javadoc.jar|*jar-with-dependencies.jar) continue;; esac + dest="$LOCAL_REPO/com/codenameone/codenameone-javase/8.0-SNAPSHOT/$(basename "$jar")" + [ -e "$dest" ] && cp -f "$jar" "$dest" +done + +# Checked again after the port build: that build resolves core as a dependency and has replaced the +# installed jar with a remote snapshot, undoing everything verified above. +JAVA_HOME="$JDK8" verify_core_class "com/codename1/ui/CodeEditor.class" "setCursorPosition" +JAVA_HOME="$JDK8" verify_core_class "com/codename1/ui/Form.class" "focusedHandlesInput" + +cd "$REPO_ROOT/scripts/guibuilder" +if [ "$run_tests" = "1" ]; then + # Built and tested on JDK 8: the editor is a Java 8 artifact so that it loads on whatever + # JDK the project being edited already builds with, and only a JDK 8 compiler proves that. + echo "==> tests (JDK 8)" + JAVA_HOME="$JDK8" PATH="$JDK8/bin:$PATH" \ + mvn -nsu -o -Dmaven.repo.local="$LOCAL_REPO" -pl javase -am test -Dcodename1.platform=javase +fi + +echo "==> package (JDK 8)" +JAVA_HOME="$JDK8" PATH="$JDK8/bin:$PATH" \ + mvn -nsu -o -q -Dmaven.repo.local="$LOCAL_REPO" -pl javase -am -Pexecutable-jar package \ + -Dcodename1.platform=javase -Dmaven.test.skip=true + +if [ "$launch" = "0" ]; then + echo "Built: scripts/guibuilder/javase/target/codenameone-guibuilder-8.0-SNAPSHOT.jar" + exit 0 +fi + +# The binding holds absolute paths, so it is generated rather than tracked. +cat > demo-project/guibuilder.input </dev/null || true +sleep 1 +echo "==> launching" +nohup "$JDK21/bin/java" \ + -Dguibuilder.input="$PWD/demo-project/guibuilder.input" \ + -Dapple.awt.application.name="Codename One GUI Builder" \ + -Dsun.awt.application.name="Codename One GUI Builder" \ + -Xdock:name="Codename One GUI Builder" \ + --add-exports=java.desktop/com.apple.eawt=ALL-UNNAMED \ + --add-exports=java.desktop/com.apple.eawt.event=ALL-UNNAMED \ + ${CN1_EXTRA_ARGS:-} \ + -jar javase/target/codenameone-guibuilder-8.0-SNAPSHOT.jar > /tmp/guibuilder.log 2>&1 & +sleep 12 +if pgrep -f "codenameone-guibuilder-8.0-SNAPSHOT.jar" > /dev/null; then + echo "GUI Builder running; log at /tmp/guibuilder.log" +else + echo "GUI Builder failed to start:" >&2 + tail -20 /tmp/guibuilder.log >&2 + exit 1 +fi diff --git a/scripts/guibuilder/common/codenameone_settings.properties b/scripts/guibuilder/common/codenameone_settings.properties new file mode 100644 index 00000000000..0722854a1c0 --- /dev/null +++ b/scripts/guibuilder/common/codenameone_settings.properties @@ -0,0 +1,12 @@ +codename1.packageName=com.codename1.guibuilder +codename1.mainName=CodenameOneGUIBuilder +codename1.displayName=Codename One GUI Builder +codename1.version=1.0 +codename1.vendor=Codename One +codename1.cssTheme=true +codename1.arg.java.version=17 +codename1.arg.nativeTheme=modern +codename1.arg.desktop.width=1440 +codename1.arg.desktop.height=900 +codename1.arg.desktop.titleBar=native +codename1.arg.desktop.interactiveScrollbars=true diff --git a/scripts/guibuilder/common/pom.xml b/scripts/guibuilder/common/pom.xml new file mode 100644 index 00000000000..e9c7cae0180 --- /dev/null +++ b/scripts/guibuilder/common/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + com.codenameone.guibuildercn1-guibuilder8.0-SNAPSHOT + cn1-guibuilder-common + common + + org.codehaus.mojoproperties-maven-plugin1.0.0initializeread-project-properties${basedir}/codenameone_settings.properties + com.codenameonecodenameone-maven-plugin${cn1.plugin.version}true + cn1-process-classesprocess-classesbytecode-compliancecssprocess-annotations + attach-test-artifacttestattach-test-artifact + + org.apache.maven.pluginsmaven-surefire-plugintruefalse + + + com.codenameonecodenameone-core + org.junit.jupiterjunit-jupiter-api5.10.2test + org.junit.jupiterjunit-jupiter-engine5.10.2test + + diff --git a/scripts/guibuilder/common/src/main/css/theme.css b/scripts/guibuilder/common/src/main/css/theme.css new file mode 100644 index 00000000000..71ec5490980 --- /dev/null +++ b/scripts/guibuilder/common/src/main/css/theme.css @@ -0,0 +1,453 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +#Constants { + includeNativeBool: true; + globalToolbarBool: true; + defaultSourceDPIInt: 0; + centeredPopupBool: true; +} + +Form, BuilderCanvasArea, BuilderStage { + background-color: #f4f6fa; + color: #182033; + font-family: "native:MainRegular"; +} + +Toolbar { + background-color: #17233b; + color: #ffffff; + padding: 1mm 2mm; + border: none; +} + +Title, TitleCommand { + background-color: transparent; + color: #ffffff; + font-family: "native:MainBold"; + font-size: 3mm; +} + +BuilderSidebar, BuilderInspector { + background-color: #ffffff; + border-right: 0.25mm solid #d8deea; +} + +BuilderInspector { + border-left: 0.25mm solid #d8deea; + border-right: none; +} + +BuilderSectionTitle { + background-color: #f8f9fc; + color: #65708a; + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 2mm 2.5mm 1.4mm 2.5mm; + border-bottom: 0.25mm solid #e1e5ee; +} + +BuilderFormItem, BuilderFormItemSelected { + background-color: transparent; + color: #313b52; + text-align: left; + font-size: 2.55mm; + padding: 1.7mm 2mm; + margin: 0.4mm 1mm; + border: none; +} + +BuilderFormItemSelected { + background-color: #eaf1ff; + color: #2459b8; + border-left: 0.7mm solid #4f8cff; +} + +BuilderHierarchyItem, BuilderHierarchySelected { + background-color: transparent; + color: #34415a; + border: none; + text-align: left; + font-size: 2.2mm; + padding: 1.2mm 1mm; + margin: 0.2mm 0.5mm; +} + +BuilderHierarchySelected { + background-color: #eaf1ff; + color: #2459b8; + border-left: 0.6mm solid #4f8cff; +} + +SplitPaneDivider { + background-color: #d8deea; +} + +SplitPaneDragHandle { + color: #71809e; +} + +BuilderFormIcon, BuilderInlineIcon, BuilderPaletteIcon { + color: #71809e; + padding: 0 1mm 0 0; +} + +BuilderPalette { + background-color: #f8f9fc; + border-top: 0.25mm solid #d8deea; + padding: 0 1mm 1mm 1mm; +} + +BuilderSearch, BuilderField, BuilderPicker { + background-color: #ffffff; + color: #182033; + border: 0.25mm solid #c7cfdd; + border-radius: 1.4mm; + padding: 1.4mm 1.6mm; + margin: 1mm; + font-size: 2.55mm; +} + +BuilderFieldError { + background-color: #fff0f0; + color: #bd3232; + border: 1px solid #d64545; + border-radius: 1.4mm; + padding: 1.4mm 1.6mm; + margin: 1mm; + font-size: 2.55mm; +} + +BuilderInlineEditor { + background-color: #ffffff; + color: #182033; + border: 2px solid #20b486; + border-radius: 1mm; + padding: 1mm; + margin: 0; + font-size: 2.7mm; +} + +BuilderPaletteItem { + background-color: #ffffff; + color: #34415a; + border: 0.25mm solid #dce1eb; + border-radius: 1.5mm; + text-align: left; + font-size: 2.2mm; + padding: 1.8mm 1.2mm; + margin: 0.6mm; +} + +BuilderCanvasToolbar { + background-color: #ffffff; + padding: 0.7mm; + border-bottom: 0.25mm solid #d8deea; +} + +BuilderCanvasButton { + background-color: transparent; + color: #5f6c86; + border: none; + border-radius: 1.2mm; + padding: 1.2mm; + margin: 0.3mm; +} + +BuilderCanvasButton.pressed { + background-color: #eaf1ff; + color: #2459b8; +} + +BuilderCanvasIcon { color: #5f6c86; } + +BuilderSelectionActions { + background-color: #192437; + border: 0.25mm solid #61708a; + border-radius: 1.8mm; + padding: 0.45mm; +} + +BuilderSelectionAction { + background-color: transparent; + color: #ffffff; + border: none; + border-radius: 1mm; + padding: 0.8mm; + margin: 0.15mm; + font-size: 2.2mm; +} + +BuilderSelectionAction.pressed { background-color: #356dcc; } + +TooltipDialog { + background-color: #192437; + border: 0.3mm solid #4f8cff; + border-radius: 1.6mm; + padding: 0; + margin: 0; +} + +Tooltip { + background-color: #192437; + color: #ffffff; + border: none; + padding: 1.2mm 2.2mm; + font-size: 2.2mm; +} + +BuilderStage { + padding: 6mm; +} + +BuilderDevice { + background-color: #ffffff; + border: 0.35mm solid #bdc6d6; + border-radius: 3mm; + padding: 4mm 2mm; + margin: 2mm; +} + +BuilderDesktopSurface { + background-color: #ffffff; + border: none; + padding: 0; + margin: 0; +} + +BuilderFormToolbar { + background-color: #17233b; + color: #ffffff; + padding: 1mm 2mm; + border-bottom: 0.25mm solid #0f182b; +} + +BuilderSelection { + background-color: #f7faff; + padding: 1mm; + border: 0.5mm solid #4f8cff; +} + +BuilderDropSpacer { + background-color: #20b486; + color: #20b486; + border: 1px solid #148766; + padding: 0; + margin: 1px; +} + +BuilderEmptyHint, BuilderEmptyProject { + background-color: #f7f9fc; + color: #7c879b; + border: 0.25mm solid #bdc6d6; + padding: 4mm; + margin: 1mm; + text-align: center; +} + +BuilderInspectorTabs { + background-color: #ffffff; +} + +TabsContainer, TabContainer { + background-color: #ffffff; + border: none; +} + +Tab, TabSelected { + background-color: #f8f9fc; + color: #68738a; + border: none; + border-bottom: 0.3mm solid #d8deea; + padding: 1.7mm 1mm; + font-size: 2.2mm; +} + +TabSelected { + background-color: #ffffff; + color: #2459b8; + border-bottom: 0.7mm solid #4f8cff; +} + +BuilderInspectorFields { + background-color: #ffffff; + padding: 1.5mm; +} + +BuilderInspectorComponent { + background-color: #eef4ff; + color: #2459b8; + font-family: "native:MainBold"; + padding: 1.7mm 2mm; + margin: 0.7mm; + border-radius: 1.4mm; +} + +BuilderCommandCard { + background-color: #f7f9fc; + border: 0.25mm solid #d8deea; + border-radius: 1.5mm; + padding: 1mm; + margin: 1mm; +} + +BuilderEditorPane { + background-color: #ffffff; + border-right: 0.25mm solid #d8deea; +} + +BuilderEditorToolbar { + background-color: #f8f9fc; + border-bottom: 0.25mm solid #d8deea; + padding: 0.5mm; +} + +BuilderEditorTitle { + color: #34415a; + font-family: "native:MainBold"; + padding: 2mm; +} + +BuilderFieldLabel { + background-color: transparent; + color: #606c83; + font-size: 2.25mm; + padding: 1.2mm 1mm 0 1mm; +} + +BuilderCheck, BuilderCheck.selected { + background-color: transparent; + color: #34415a; + border: none; + padding: 1.2mm 1mm; + font-size: 2.45mm; +} + +BuilderHelp { + background-color: #f7f9fc; + color: #68738a; + font-size: 2.25mm; + padding: 2mm; + margin: 1mm; + border-radius: 1.2mm; +} + +BuilderPrimaryAction, BuilderSecondaryAction, BuilderDangerAction { + background-color: #3677e8; + color: #ffffff; + border: none; + border-radius: 1.5mm; + padding: 1.7mm 2mm; + margin: 1mm; + font-size: 2.45mm; +} + +BuilderSecondaryAction { + background-color: #eef2f8; + color: #34415a; +} + +BuilderDangerAction { + background-color: #fff0f0; + color: #bd3232; +} + +BuilderDangerIcon { color: #bd3232; } + +BuilderStatus { + background-color: #17233b; + color: #d9e2f4; + padding: 1mm 2mm; + font-size: 2.1mm; +} + +BuilderWelcome { + background-color: #ffffff; + border: 0.25mm solid #d8deea; + border-radius: 2.5mm; + padding: 7mm; +} + +BuilderWelcomeTitle { + color: #182033; + font-family: "native:MainBold"; + font-size: 5mm; + text-align: center; +} + +BuilderWelcomeCopy { + color: #68738a; + font-size: 2.8mm; + text-align: center; + padding: 2mm; +} + +@media (prefers-color-scheme: dark) { + Form, BuilderCanvasArea, BuilderStage { background-color: #071B4D; color: #F5F8FF; } + Toolbar { background-color: #071B4D; color: #F5F8FF; } + Title, TitleCommand { background-color: transparent; color: #F5F8FF; } + BuilderSidebar { background-color: #102B66; border-right: 1px solid #36558F; } + BuilderInspector { background-color: #102B66; border-left: 1px solid #36558F; border-right: none; } + BuilderSectionTitle { background-color: #163575; color: #D8E2F7; border-bottom: 1px solid #36558F; } + BuilderFormItem { background-color: transparent; color: #F5F8FF; } + BuilderFormItemSelected { background-color: #21478F; color: #F5F8FF; border-left-color: #4D86FF; } + BuilderHierarchyItem { background-color: transparent; color: #F5F8FF; } + BuilderHierarchySelected { background-color: #21478F; color: #F5F8FF; border-left-color: #4D86FF; } + SplitPaneDivider { background-color: #36558F; } + SplitPaneDragHandle, BuilderFormIcon, BuilderInlineIcon, BuilderPaletteIcon, BuilderCanvasIcon { color: #A8B8DA; } + BuilderPalette { background-color: #163575; border-top: 1px solid #36558F; } + BuilderSearch, BuilderField, BuilderPicker { background-color: #0E2A61; color: #F5F8FF; border: 1px solid #5876A9; } + BuilderFieldError { background-color: #4B2940; color: #FFC2CC; border: 1px solid #D96A7A; } + BuilderInlineEditor { background-color: #0E2A61; color: #F5F8FF; border: 2px solid #41D6A4; } + BuilderPaletteItem { background-color: #102B66; color: #F5F8FF; border: 1px solid #36558F; } + BuilderCanvasToolbar { background-color: #102B66; border-bottom: 1px solid #36558F; } + BuilderCanvasButton { background-color: transparent; color: #A8B8DA; } + BuilderCanvasButton.pressed { background-color: #21478F; color: #F5F8FF; } + BuilderSelectionActions { background-color: #07132F; border: 1px solid #5876A9; } + BuilderSelectionAction { background-color: transparent; color: #F5F8FF; } + BuilderSelectionAction.pressed { background-color: #21478F; } + BuilderDevice { background-color: #102B66; border: 1px solid #5876A9; } + BuilderDesktopSurface { background-color: #071B4D; border: none; } + BuilderFormToolbar { background-color: #071B4D; color: #F5F8FF; border-bottom: 1px solid #36558F; } + BuilderSelection { background-color: #163575; border: 2px solid #4D86FF; } + BuilderDropSpacer { background-color: #41D6A4; color: #41D6A4; border: 1px solid #20B486; } + BuilderEmptyHint, BuilderEmptyProject { background-color: #163575; color: #D8E2F7; border: 1px solid #5876A9; } + BuilderInspectorTabs, TabsContainer, TabContainer, BuilderInspectorFields { background-color: #102B66; } + Tab { background-color: #163575; color: #D8E2F7; border: none; border-bottom: 1px solid #36558F; } + TabSelected { background-color: #102B66; color: #F5F8FF; border-bottom-color: #4D86FF; } + BuilderInspectorComponent { background-color: #21478F; color: #F5F8FF; } + BuilderCommandCard { background-color: #163575; border: 1px solid #36558F; } + BuilderEditorPane { background-color: #102B66; border-right: 1px solid #36558F; } + BuilderEditorToolbar { background-color: #102B66; border-bottom: 1px solid #36558F; } + BuilderEditorTitle { color: #F5F8FF; } + BuilderFieldLabel { background-color: transparent; color: #A8B8DA; } + BuilderCheck, BuilderCheck.selected { background-color: transparent; color: #F5F8FF; border: none; } + BuilderHelp { background-color: #163575; color: #D8E2F7; } + BuilderPrimaryAction { background-color: #4D86FF; color: #F5F8FF; } + BuilderSecondaryAction { background-color: #163575; color: #F5F8FF; border: 1px solid #36558F; } + BuilderDangerAction { background-color: #4B2940; color: #FFC2CC; } + BuilderStatus { background-color: #071B4D; color: #F5F8FF; } + BuilderWelcome { background-color: #102B66; border: 1px solid #36558F; } + BuilderWelcomeTitle { color: #F5F8FF; } + BuilderWelcomeCopy { color: #A8B8DA; } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java new file mode 100644 index 00000000000..efa1cfe3724 --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -0,0 +1,5515 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.components.SpanLabel; +import com.codename1.components.SplitPane; +import com.codename1.components.ToastBar; +import com.codename1.guibuilder.model.GuiDocument; +import com.codename1.guibuilder.project.ProjectBinding; +import com.codename1.guibuilder.project.ProjectIO; +import com.codename1.guibuilder.ui.ComponentPreviewFactory; +import com.codename1.guibuilder.ui.DragGuideOverlay; +import com.codename1.guibuilder.ui.GuidedLayoutSupport; +import com.codename1.io.Log; +import com.codename1.io.Preferences; +import com.codename1.mcp.MCP; +import com.codename1.system.Lifecycle; +import com.codename1.ui.Button; +import com.codename1.ui.CheckBox; +import com.codename1.ui.CodeDiagnostic; +import com.codename1.ui.CodeEditor; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Font; +import com.codename1.ui.Label; +import com.codename1.ui.Tabs; +import com.codename1.ui.TextField; +import com.codename1.ui.TextArea; +import com.codename1.ui.Toolbar; +import com.codename1.ui.TooltipManager; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.FocusListener; +import com.codename1.ui.events.PointerEvent; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.accessibility.AccessibilityGrouping; +import com.codename1.ui.accessibility.AccessibilityLiveRegion; +import com.codename1.ui.accessibility.AccessibilityRole; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.css.CSSThemeCompiler; +import com.codename1.ui.spinner.Picker; +import com.codename1.ui.util.MutableResource; +import com.codename1.ui.util.Resources; +import com.codename1.xml.Element; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.Hashtable; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class CodenameOneGUIBuilder extends Lifecycle { + private static CodenameOneGUIBuilder active; + private ProjectBinding binding; + private List guiFiles = new ArrayList<>(); + private GuiDocument document; + private Form workspace; + private Container formsPanel; + private Container hierarchyPanel; + private Container canvasHost; + private Container canvasOverlayHost; + private Container inspectorHost; + private Label status; + private String paletteFilter = ""; + private Map javaNames = new LinkedHashMap<>(); + private String clipboardXml; + private boolean darkMode; + private String canvasMode = "phonePortrait"; + private Element dropGuideTarget; + private Hashtable projectTheme; + /// The look and feel of the design canvas. The preview hierarchy owns it so the project theme + /// and the builder's own chrome can coexist; they cannot share the global UIManager. + private UIManager previewUIManager = UIManager.createInstance(); + /// The device surface the canvas UIManager is attached to, so a new one can replace it. + private Container deviceSurface; + private int themeApplyCount; + private Hashtable builderTheme; + private Component previewRoot; + private Component formToolbarPreview; + private Label formTitlePreview; + private DragGuideOverlay dragGuideOverlay; + private Container selectionActions; + private final LinkedHashSet selectedElements = new LinkedHashSet<>(); + private Component boxDropSpacer; + private Element boxDropTargetElement; + private Container boxDropParent; + private int boxDropIndex = -1; + private int inspectorTabIndex; + private boolean refreshPending; + private TextField inlineEditor; + private Component inlineEditorSource; + private Container inlineEditorParent; + private Element inlineEditorTarget; + private String inlineEditorAttribute; + private boolean finishingInlineEditor; + private DropPlan activeDropPlan; + private CodeEditor activeCodeEditor; + /** Reopens whichever editor was on screen after the canvas is rebuilt; null when none is. */ + private Runnable activeEditorReopen; + private boolean reopeningEditor; + /** + * Live mirror of the open editor's text, and the text it last agreed with on disk. A canvas + * rebuild destroys the editor component, so without a mirror the reopen re-read the file and + * every unsaved keystroke vanished the moment the user dropped, deleted or undid anything. + * The pair also answers whether the buffer is dirty, which is what Close has to know. + */ + private String editorBuffer; + private String editorBufferOnDisk; + private String lastObservedCss; + private com.codename1.ui.util.UITimer cssLiveTimer; + private int cssEditRevision; + private Element designerDraggedElement; + private GuiDocument designerDragDocument; + private String designerPaletteType; + private Component designerDragSource; + private Runnable designerSuppressAction; + private int designerPressX; + private int designerPressY; + private int designerGrabOffsetX; + private int designerGrabOffsetY; + private boolean designerDragArmed; + private boolean designerDragActive; + private Element guidedResizeElement; + private Component guidedResizeSource; + private int guidedResizeEdges; + private int guidedResizePressX; + private int guidedResizePressY; + private int guidedResizeStartX; + private int guidedResizeStartY; + private int guidedResizeStartW; + private int guidedResizeStartH; + private boolean guidedResizeArmed; + private boolean guidedResizeActive; + private Component designerCursorComponent; + private ResizePlan activeResizePlan; + private GuiBuilderMcpController mcpController; + private String lastDragJournalSignature; + + @Override + public void init(Object context) { + super.init(context); + active = this; + Resources global = Resources.getGlobalResources(); + String[] themeNames = global == null ? new String[0] : global.getThemeResourceNames(); + builderTheme = themeNames.length == 0 ? new Hashtable() : global.getTheme(themeNames[0]); + Display.getInstance().setProperty("GUIBuilderDesignMode", "true"); + Display.getInstance().setDragStartPercentage(1); + binding = ProjectIO.loadBinding(); + String requestedDarkMode = System.getProperty("guibuilder.darkMode"); + darkMode = requestedDarkMode == null + ? Preferences.get("guibuilder.darkMode", Boolean.TRUE.equals(Display.getInstance().isDarkMode())) + : Boolean.parseBoolean(requestedDarkMode); + Display.getInstance().setDarkMode(Boolean.valueOf(darkMode)); + Log.bindCrashProtection(false); + } + + @Override + public void runApp() { + Toolbar.setGlobalToolbar(true); + TooltipManager.enableTooltips(); + if (binding == null) { + showUnboundState(); + return; + } + guiFiles = ProjectIO.findGuiFiles(binding.guiDir()); + String requestedCanvasMode = System.getProperty("guibuilder.canvasMode"); + if (requestedCanvasMode != null && requestedCanvasMode.length() > 0) canvasMode = requestedCanvasMode; + workspace = new Form("Codename One GUI Builder", new BorderLayout()) { + @Override + public void pointerHover(int[] x, int[] y) { + if (x != null && y != null && x.length > 0 && y.length > 0) { + updateDesignerHoverCursor(x[0], y[0]); + } + super.pointerHover(x, y); + } + }; + workspace.setName("GUIBuilderWorkspace"); + workspace.setEnableCursors(true); + workspace.getSemantics().setIdentifier("guibuilder.workspace") + .setPaneTitle("Codename One GUI Builder").setGrouping(AccessibilityGrouping.GROUP); + workspace.getToolbar().setTitleCentered(false); + installToolbar(workspace.getToolbar()); + workspace.add(BorderLayout.CENTER, buildWorkspace()); + status = new Label("Ready", "BuilderStatus"); + status.getSemantics().setIdentifier("guibuilder.status").setLabel("GUI Builder status") + .setLiveRegion(AccessibilityLiveRegion.POLITE); + workspace.add(BorderLayout.SOUTH, status); + workspace.show(); + installDragGuideOverlay(); + if (!guiFiles.isEmpty()) { + String initial = resolveInitialForm(); + openForm(initial == null ? guiFiles.get(0) : initial); + String initialSelection = System.getProperty("guibuilder.initialSelection"); + if (initialSelection != null && initialSelection.length() > 0) { + String[] names = initialSelection.split(","); + for (int i = 0; i < names.length; i++) { + Element element = findElementNamed(document, names[i].trim()); + if (element != null) selectElement(element, i > 0, "startup"); + } + } + if ("css".equals(System.getProperty("guibuilder.openEditor"))) { + Display.getInstance().callSerially(this::openCss); + } else if ("java".equals(System.getProperty("guibuilder.openEditor"))) { + Display.getInstance().callSerially(this::openCompanionSource); + } + } else { + showEmptyProject(); + } + installWorkspacePointerRouting(); + configureMcp(); + } + + private void configureMcp() { + mcpController = new GuiBuilderMcpController(this); + mcpController.register(); + recordAction("mcp_ready", "socketSupported", Boolean.valueOf(MCP.isSocketSupported())); + String requestedPort = System.getProperty("guibuilder.mcp.port"); + if (requestedPort == null || requestedPort.trim().length() == 0 || MCP.isRunning()) return; + try { + int port = Integer.parseInt(requestedPort.trim()); + if (port < 1 || port > 65535) throw new NumberFormatException("port out of range"); + MCP.startSocketServer(port); + recordAction("mcp_starting", "port", Integer.valueOf(port)); + setStatus("MCP starting on 127.0.0.1:" + port); + final int checkedPort = port; + new com.codename1.ui.util.UITimer(() -> { + if (MCP.isRunning()) { + recordAction("mcp_started", "port", Integer.valueOf(checkedPort)); + setStatus("Ready • MCP listening on 127.0.0.1:" + checkedPort); + } else { + recordAction("mcp_error", "message", "Unable to bind 127.0.0.1:" + checkedPort); + setStatus("MCP unavailable • port " + checkedPort + " is already in use"); + } + }).schedule(700, false, workspace); + } catch (Throwable ex) { + Log.e(ex); + recordAction("mcp_error", "message", ex.getMessage()); + setStatus("MCP unavailable • " + (ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage())); + } + } + + /** Form initialization may transfer pointer listeners to an owning form. Install the + * designer's capture routing only after show(), when this is definitively the active form. */ + private void installWorkspacePointerRouting() { + workspace.addPointerPressedListener(e -> handleDesignerPointerPressed(e.getX(), e.getY(), additiveSelection(e))); + workspace.addPointerDraggedListener(e -> { + if (guidedResizeArmed) updateGuidedResize(e.getX(), e.getY()); + else updateDesignerDrag(e.getX(), e.getY()); + }); + workspace.addPointerReleasedListener(e -> { + if (guidedResizeArmed) finishGuidedResize(e.getX(), e.getY()); + else finishDesignerDrag(e.getX(), e.getY()); + }); + } + + void handleDesignerPointerPressed(int x, int y) { + handleDesignerPointerPressed(x, y, false); + } + + void handleDesignerPointerPressed(int x, int y, boolean additive) { + if (selectionActions != null && selectionActions.isVisible() + && x >= selectionActions.getAbsoluteX() && x <= selectionActions.getAbsoluteX() + selectionActions.getWidth() + && y >= selectionActions.getAbsoluteY() && y <= selectionActions.getAbsoluteY() + selectionActions.getHeight()) return; + TextField editor = inlineEditor; + if (editor != null && (x < editor.getAbsoluteX() || x > editor.getAbsoluteX() + editor.getWidth() + || y < editor.getAbsoluteY() || y > editor.getAbsoluteY() + editor.getHeight())) { + finishInlineEditor(); + } + Element hit = null; + if (inlineEditor == null && canvasHost != null + && x >= canvasHost.getAbsoluteX() && x <= canvasHost.getAbsoluteX() + canvasHost.getWidth() + && y >= canvasHost.getAbsoluteY() && y <= canvasHost.getAbsoluteY() + canvasHost.getHeight()) { + hit = elementAt(canvasHost, x, y); + } + boolean duplicateComponentPress = designerDragArmed && hit == designerDraggedElement + && Math.abs(x - designerPressX) + Math.abs(y - designerPressY) <= 2; + boolean duplicateResizePress = guidedResizeArmed && hit == guidedResizeElement + && Math.abs(x - guidedResizePressX) + Math.abs(y - guidedResizePressY) <= 2; + if (duplicateComponentPress || duplicateResizePress) return; + if (designerDragArmed || guidedResizeArmed) cancelDesignerDrag(); + if (hit != null && document != null && hit != document.root() + && (additive || !selectedElements.contains(hit) + || (selectedElements.size() > 1 && hit != document.selected()))) { + selectElement(hit, additive, "canvas"); + } + // Resize hit-testing must use the component under this press, never the previously + // selected component. Otherwise an ordinary click on an overlapping neighbor is stolen. + if (!additive && hit != null && document != null && selectedElements.contains(hit) + && beginGuidedResize(x, y)) return; + if (hit != null && document != null && hit != document.root() && selectedElements.contains(hit)) { + armDesignerDrag(hit, null, componentForElement(canvasHost, hit), x, y, null); + } + } + + private static boolean additiveSelection(ActionEvent event) { + PointerEvent pointer = event == null ? null : event.getPointerEvent(); + return pointer != null && (pointer.isShiftDown() || pointer.isControlDown() || pointer.isMetaDown()); + } + + private void showUnboundState() { + Form form = new Form("Codename One GUI Builder", new BorderLayout()); + Container empty = new Container(BoxLayout.y()); + empty.setUIID("BuilderWelcome"); + empty.add(new Label("GUI Builder", "BuilderWelcomeTitle")); + empty.add(new SpanLabel("Open the builder from a Codename One Maven project with mvn cn1:guibuilder.", "BuilderWelcomeCopy")); + form.add(BorderLayout.CENTER, BorderLayout.centerAbsolute(empty)); + form.show(); + } + + private Component buildWorkspace() { + formsPanel = new Container(BoxLayout.y()); + formsPanel.setUIID("BuilderSidebar"); + formsPanel.setScrollableY(true); + formsPanel.getSemantics().setIdentifier("guibuilder.forms").setLabel("Project forms") + .setRole(AccessibilityRole.LIST).setGrouping(AccessibilityGrouping.GROUP); + hierarchyPanel = new Container(BoxLayout.y()); + hierarchyPanel.setUIID("BuilderSidebar"); + hierarchyPanel.setScrollableY(true); + hierarchyPanel.getSemantics().setIdentifier("guibuilder.hierarchy").setLabel("Component hierarchy") + .setRole(AccessibilityRole.TREE).setGrouping(AccessibilityGrouping.GROUP); + canvasHost = new Container(new BorderLayout()); + canvasHost.setUIID("BuilderCanvasArea"); + canvasHost.getSemantics().setIdentifier("guibuilder.canvas").setLabel("Design canvas") + .setGrouping(AccessibilityGrouping.GROUP); + canvasOverlayHost = new Container(new LayeredLayout()); + canvasOverlayHost.setUIID("BuilderCanvasArea"); + canvasOverlayHost.add(canvasHost); + ((LayeredLayout) canvasOverlayHost.getLayout()).setInsets(canvasHost, "0 0 0 0"); + inspectorHost = new Container(new BorderLayout()); + inspectorHost.setUIID("BuilderInspector"); + inspectorHost.getSemantics().setIdentifier("guibuilder.inspector").setLabel("Inspector") + .setGrouping(AccessibilityGrouping.GROUP); + + Container left = new Container(new BorderLayout()); + left.setUIID("BuilderSidebar"); + left.add(BorderLayout.NORTH, sectionTitle("PROJECT FORMS")); + Container leftBody = new Container(new GridLayout(2, 1)); + leftBody.setUIID("BuilderSidebar"); + Tabs projectTabs = new Tabs(); + projectTabs.getSemantics().setIdentifier("guibuilder.projectTabs").setLabel("Project navigation"); + projectTabs.setSwipeActivated(false); + projectTabs.addTab("Forms", formsPanel); + projectTabs.addTab("Hierarchy", hierarchyPanel); + leftBody.add(projectTabs); + leftBody.add(buildPalette()); + left.add(BorderLayout.CENTER, leftBody); + refreshForms(); + + Container center = new Container(new BorderLayout()); + center.setUIID("BuilderCanvasArea"); + center.setScrollableX(true); + center.add(BorderLayout.NORTH, buildCanvasToolbar()); + center.add(BorderLayout.CENTER, canvasOverlayHost); + + Container right = new Container(new BorderLayout()); + right.setUIID("BuilderInspector"); + right.add(BorderLayout.NORTH, sectionTitle("INSPECTOR")); + right.add(BorderLayout.CENTER, inspectorHost); + + SplitPane centerAndInspector = new SplitPane(SplitPane.HORIZONTAL_SPLIT, center, right, + "45%", "72%", "90%"); + SplitPane workspaceSplit = new SplitPane(SplitPane.HORIZONTAL_SPLIT, left, centerAndInspector, + "10%", "20%", "40%"); + return workspaceSplit; + } + + private void installDragGuideOverlay() { + dragGuideOverlay = new DragGuideOverlay(); + canvasOverlayHost.add(dragGuideOverlay); + ((LayeredLayout) canvasOverlayHost.getLayout()).setInsets(dragGuideOverlay, "0 0 0 0"); + selectionActions = buildSelectionActions(); + selectionActions.setVisible(false); + canvasOverlayHost.add(selectionActions); + ((LayeredLayout) canvasOverlayHost.getLayout()).setInsets(selectionActions, "0 auto auto 0"); + canvasOverlayHost.revalidate(); + } + + private Container buildSelectionActions() { + Container palette = new Container(new GridLayout(1, 9)); + palette.setUIID("BuilderSelectionActions"); + palette.getSemantics().setIdentifier("guibuilder.selectionActions") + .setLabel("Selected component layout actions").setGrouping(AccessibilityGrouping.GROUP); + palette.add(selectionAction(FontImage.MATERIAL_ALIGN_HORIZONTAL_LEFT, + "Align left edges to the filled-handle reference component", "alignLeft")); + palette.add(selectionAction(FontImage.MATERIAL_ALIGN_HORIZONTAL_CENTER, + "Align horizontal centers to the filled-handle reference component", "alignHCenter")); + palette.add(selectionAction(FontImage.MATERIAL_ALIGN_HORIZONTAL_RIGHT, + "Align right edges to the filled-handle reference component", "alignRight")); + palette.add(selectionAction(FontImage.MATERIAL_ALIGN_VERTICAL_TOP, + "Align top edges to the filled-handle reference component", "alignTop")); + palette.add(selectionAction(FontImage.MATERIAL_TEXT_FIELDS, + "Align text baselines to the filled-handle reference component", "alignBaseline")); + palette.add(selectionAction(FontImage.MATERIAL_ALIGN_VERTICAL_BOTTOM, + "Align bottom edges to the filled-handle reference component", "alignBottom")); + palette.add(selectionAction(FontImage.MATERIAL_WIDTH_FULL, + "Make every selected component as wide as the filled-handle reference component", "matchWidth")); + palette.add(selectionAction(FontImage.MATERIAL_HEIGHT, + "Make every selected component as tall as the filled-handle reference component", "matchHeight")); + palette.add(selectionAction(FontImage.MATERIAL_LINK_OFF, + "Disconnect selected components from alignment and size relationships", "disconnect")); + return palette; + } + + private Button selectionAction(char icon, String tooltip, String action) { + Button button = new Button(""); + button.setUIID("BuilderSelectionAction"); + button.setTooltip(tooltip); + button.setCursor(Component.HAND_CURSOR); + button.setAlignment(Component.CENTER); + button.getAllStyles().setAlignment(Component.CENTER); + FontImage.setMaterialIcon(button, icon, 3.2f); + button.getSemantics().setIdentifier("guibuilder.selectionAction." + action).setLabel(tooltip); + button.addActionListener(e -> applySelectionAction(action)); + return button; + } + + private void installToolbar(Toolbar toolbar) { + toolbar.addMaterialCommandToLeftBar("Save", FontImage.MATERIAL_SAVE, e -> save()); + toolbar.addMaterialCommandToLeftBar("Undo", FontImage.MATERIAL_UNDO, e -> undo()); + toolbar.addMaterialCommandToLeftBar("Redo", FontImage.MATERIAL_REDO, e -> redo()); + toolbar.addMaterialCommandToLeftBar("Refresh", FontImage.MATERIAL_REFRESH, e -> refreshProject()); + toolbar.addMaterialCommandToRightBar("CSS", FontImage.MATERIAL_COLOR_LENS, e -> openCss()); + toolbar.addMaterialCommandToRightBar("Code", FontImage.MATERIAL_CODE, e -> openCompanionSource()); + } + + private Component buildCanvasToolbar() { + Container bar = new Container(new FlowLayout(Component.CENTER)); + bar.setUIID("BuilderCanvasToolbar"); + bar.add(iconButton(FontImage.MATERIAL_STAY_CURRENT_PORTRAIT, "Phone portrait", () -> setCanvasMode("phonePortrait"))); + bar.add(iconButton(FontImage.MATERIAL_STAY_CURRENT_LANDSCAPE, "Phone landscape", () -> setCanvasMode("phoneLandscape"))); + bar.add(iconButton(FontImage.MATERIAL_TABLET, "Tablet portrait", () -> setCanvasMode("tabletPortrait"))); + bar.add(iconButton(FontImage.MATERIAL_DESKTOP_MAC, "Desktop — full canvas", () -> setCanvasMode("desktop"))); + return bar; + } + + private Component buildPalette() { + Container palette = new Container(new BorderLayout()); + palette.setUIID("BuilderPalette"); + palette.getSemantics().setIdentifier("guibuilder.palette").setLabel("Component palette") + .setRole(AccessibilityRole.LIST).setGrouping(AccessibilityGrouping.GROUP); + TextField search = new TextField("", "Search components"); + search.setUIID("BuilderSearch"); + search.getSemantics().setIdentifier("guibuilder.palette.search").setLabel("Search components") + .setRole(AccessibilityRole.SEARCH_FIELD); + search.addDataChangedListener((type, index) -> { + paletteFilter = search.getText().toLowerCase(); + rebuildPalette(palette); + }); + palette.putClientProperty("search", search); + rebuildPalette(palette); + return palette; + } + + private void rebuildPalette(Container palette) { + TextField search = (TextField) palette.getClientProperty("search"); + palette.removeAll(); + Container heading = new Container(BoxLayout.y()); + heading.add(sectionTitle("COMPONENTS")); + heading.add(search); + palette.add(BorderLayout.NORTH, heading); + Container items = new Container(new GridLayout(5, 2)); + items.setScrollableY(true); + String[] types = {"Button", "Label", "SpanLabel", "TextField", "TextArea", "CheckBox", "RadioButton", "Slider", "Container", "Tabs"}; + char[] icons = {FontImage.MATERIAL_SMART_BUTTON, FontImage.MATERIAL_TEXT_FIELDS, FontImage.MATERIAL_SUBJECT, + FontImage.MATERIAL_EDIT, FontImage.MATERIAL_NOTES, FontImage.MATERIAL_CHECK_BOX, + FontImage.MATERIAL_RADIO_BUTTON_CHECKED, FontImage.MATERIAL_TUNE, FontImage.MATERIAL_VIEW_AGENDA, + FontImage.MATERIAL_TAB}; + for (int i = 0; i < types.length; i++) { + String type = types[i]; + if (paletteFilter.length() > 0 && !type.toLowerCase().contains(paletteFilter)) continue; + Button item = new Button(type, material(icons[i], "BuilderPaletteIcon")); + item.setUIID("BuilderPaletteItem"); + item.getSemantics().setIdentifier("guibuilder.palette." + type.toLowerCase()) + .setLabel("Add " + type).setHint("Activate to add, or drag onto the design canvas"); + final boolean[] suppressAction = new boolean[1]; + item.addActionListener(e -> { + if (suppressAction[0]) { + suppressAction[0] = false; + return; + } + addComponent(type); + }); + item.addPointerPressedListener(e -> armDesignerDrag(null, type, item, e.getX(), e.getY(), + () -> suppressAction[0] = true)); + items.add(item); + } + palette.add(BorderLayout.CENTER, items); + palette.revalidate(); + } + + private void refreshForms() { + formsPanel.removeAll(); + for (String path : guiFiles) { + String relative = relativeFormName(path); + String simple = relative.substring(relative.lastIndexOf('.') + 1); + Button form = new Button(simple, material(FontImage.MATERIAL_INSERT_DRIVE_FILE, "BuilderFormIcon")); + form.setTooltip(relative); + form.getSemantics().setIdentifier("guibuilder.form." + relative).setLabel(simple) + .setDescription(relative).setRole(AccessibilityRole.LIST_ITEM) + .setSelected(Boolean.valueOf(document != null && path.equals(document.path()))); + form.setUIID(document != null && path.equals(document.path()) ? "BuilderFormItemSelected" : "BuilderFormItem"); + form.addActionListener(e -> switchForm(path)); + formsPanel.add(form); + } + formsPanel.revalidate(); + } + + private void switchForm(String path) { + cancelDesignerDrag(); + // Opening another form tears the editor pane down, and its buffer is the only copy of an + // unsaved edit. The document itself can be perfectly clean while the source, CSS or model + // pane is not, so this has to be asked separately. + if (editorBufferIsDirty() + && !com.codename1.ui.Dialog.show("Unsaved changes", + "The open editor has changes you have not saved. Opening another form" + + " discards them.", "Discard", "Keep editing")) { + setStatus("Kept your unsaved editor changes"); + return; + } + if (document != null && document.isModified()) { + if (com.codename1.ui.Dialog.show("Unsaved changes", "Save changes before switching forms?", "Save", "Discard")) { + // Staying on a form whose save failed is the only way to keep the work: opening the + // next form replaces the document and the edits are gone. + if (!save()) { + setStatus("Still editing " + relativeFormName(document.path()) + "; the save failed"); + return; + } + } + } + openForm(path); + } + + /** + * @return true when the form was parsed and is now the document being edited. Callers that + * report success to a client have to know: a malformed file left the previous form on + * screen while the caller announced the new one. + */ + private boolean openForm(String path) { + try { + cancelDesignerDrag(); + document = GuiDocument.parse(path, ProjectIO.read(path)); + activeEditorReopen = null; + selectedElements.clear(); + recordAction("form_opened", "form", relativeFormName(path)); + loadProjectTheme(); + refreshForms(); + refreshEditor(); + setStatus("Editing " + relativeFormName(path)); + return true; + } catch (Exception ex) { + ToastBar.showErrorMessage("Unable to open GUI file: " + ex.getMessage()); + return false; + } + } + + private void refreshEditor() { + if (document == null) return; + TooltipManager.hideTooltip(); + normalizeSelection(); + cancelDesignerDrag(); + activeCodeEditor = null; + finishInlineEditor(); + canvasHost.removeAll(); + boolean desktop = "desktop".equals(canvasMode); + Container stage = new Container(desktop ? new BorderLayout() : new FlowLayout(Component.CENTER)); + stage.setUIID("BuilderStage"); + final int surfaceWidth = desktop ? -1 : "phoneLandscape".equals(canvasMode) ? 1200 + : "tabletPortrait".equals(canvasMode) ? 960 : 720; + final int surfaceHeight = desktop ? -1 : "phoneLandscape".equals(canvasMode) ? 720 + : "tabletPortrait".equals(canvasMode) ? 1280 : 1200; + Container device = new Container(new BorderLayout()) { + @Override protected Dimension calcPreferredSize() { + Dimension natural = super.calcPreferredSize(); + if (surfaceWidth > 0) natural.setWidth(surfaceWidth); + if (surfaceHeight > 0) natural.setHeight(surfaceHeight); + return natural; + } + }; + device.setUIID(desktop ? "BuilderDesktopSurface" : "BuilderDevice"); + formToolbarPreview = buildFormToolbarPreview(); + device.add(BorderLayout.NORTH, formToolbarPreview); + Component preview = ComponentPreviewFactory.create(document.root(), document.selected(), new ComponentPreviewFactory.SelectionHandler() { + @Override public void selected(Element element) { + selectElement(element, false, "preview"); + } + @Override public void selected(Element element, boolean additive) { + selectElement(element, additive, "preview"); + } + @Override public void dragPressed(Element element, Component source, int x, int y) { + if (!selectedElements.contains(element)) return; + if (selectedElements.size() <= 1 && !beginGuidedResize(x, y)) { + armDesignerDrag(element, null, source, x, y, null); + } else if (selectedElements.size() > 1) { + armDesignerDrag(element, null, source, x, y, null); + } + } + @Override public boolean isDragActive() { return designerDragActive; } + @Override public void dragMoved(int x, int y) { + if (guidedResizeArmed) updateGuidedResize(x, y); else updateDesignerDrag(x, y); + } + @Override public void dragReleased(int x, int y) { + if (guidedResizeArmed) finishGuidedResize(x, y); else finishDesignerDrag(x, y); + } + @Override public void editContent(Element element) { + selectElement(element, false, "inline-edit"); + editSelectedContent(); + } + }); + previewRoot = preview; + // The whole device surface resolves its look and feel from the canvas UIManager, so the + // project CSS styles the preview and the builder's own theme never leaks into it. + deviceSurface = device; + device.setUIManager(previewUIManager); + if (preview instanceof Container) ((Container) preview).setUIManager(previewUIManager); + refreshProjectThemeOnPreview(); + device.add(BorderLayout.CENTER, preview); + if (desktop) stage.add(BorderLayout.CENTER, device); else stage.add(device); + canvasHost.add(BorderLayout.CENTER, stage); + refreshInspector(); + refreshHierarchy(); + canvasHost.revalidate(); + Display.getInstance().callSerially(this::refreshGuidedSelectionOverlay); + // Rebuilding the canvas replaces everything inside it, including the split pane an open + // editor lives in -- so any drop, delete or undo silently closed the editor mid-edit. + // Put it back. The guard stops the reopen from recursing through this method. + Runnable reopen = activeEditorReopen; + if (reopen != null && !reopeningEditor) { + reopeningEditor = true; + try { + reopen.run(); + } finally { + reopeningEditor = false; + } + } + } + + /** + * Starts mirroring an editor's text so a canvas rebuild can put the buffer back and Close can + * tell whether there is anything to lose. + * + * @param editor the editor being shown + * @param content the text it was opened with + */ + private void trackEditorBuffer(final CodeEditor editor, String content) { + editorBuffer = content; + // Only the reopen path carries a buffer forward; a fresh open agrees with disk by + // definition, and after a save the caller resets this to the text it wrote. + if (!reopeningEditor) editorBufferOnDisk = content; + editor.addChangeListener(e -> editor.getText(text -> editorBuffer = text)); + } + + /** True when the open editor holds text that is not on disk. */ + private boolean editorBufferIsDirty() { + return editorBuffer != null && !editorBuffer.equals(editorBufferOnDisk); + } + + /** + * Closes an open editor pane for good, rather than letting the next refresh restore it. The + * buffer is the only copy of an unsaved edit -- reopening reads the file -- so closing without + * asking discarded the user's work outright. + */ + private void closeEditorPane() { + if (editorBufferIsDirty() + && !com.codename1.ui.Dialog.show("Unsaved changes", + "Closing this editor discards the changes you have not saved.", + "Discard", "Keep editing")) { + return; + } + activeEditorReopen = null; + editorBuffer = null; + editorBufferOnDisk = null; + refreshEditor(); + } + + private Component buildFormToolbarPreview() { + Container bar = new Container(new BorderLayout()); + bar.setUIID("BuilderFormToolbar"); + formTitlePreview = new Label(document.root().getAttribute("title") == null ? "Untitled Form" : document.root().getAttribute("title"), "Title"); + formTitlePreview.getSemantics().setIdentifier("guibuilder.preview.formTitle") + .setLabel("Form title").setHint("Double click to edit the form title"); + formTitlePreview.addLongPressListener(e -> { document.select(document.root()); editSelectedContent(); }); + formTitlePreview.addPointerReleasedListener(e -> { + long now = System.currentTimeMillis(); + Object previous = formTitlePreview.getClientProperty("gui.lastClick"); + formTitlePreview.putClientProperty("gui.lastClick", Long.valueOf(now)); + if (previous instanceof Long && now - ((Long) previous).longValue() < 450) { + document.select(document.root()); + editSelectedContent(); + } + }); + bar.add(BorderLayout.CENTER, formTitlePreview); + Container left = new Container(BoxLayout.x()); + Container right = new Container(BoxLayout.x()); + for (Element command : document.commands()) { + Button button = new Button(value(command, "name", "Command")); + button.setUIID("TitleCommand"); + // The command editor offers overflow and side placements and the generated source + // honours them. Falling through to the right bar here showed a toolbar the running + // application would never produce, so placement could not be checked before saving. + String placement = value(command, "placement", "right"); + if ("side".equals(placement)) { + // A side command reaches the user through the side menu and an overflow command + // through the overflow menu; neither sits on the bar as its own button. Showing + // where it actually lives beats showing it in the wrong corner. + button.setText("[side] " + button.getText()); + } else if ("overflow".equals(placement)) { + button.setText("[overflow] " + button.getText()); + } + if ("left".equals(placement) || "side".equals(placement)) left.add(button); else right.add(button); + } + if (left.getComponentCount() > 0) bar.add(BorderLayout.WEST, left); + if (right.getComponentCount() > 0) bar.add(BorderLayout.EAST, right); + return bar; + } + + private void refreshHierarchy() { + hierarchyPanel.removeAll(); + if (document != null) addHierarchyRow(document.root(), 0); + hierarchyPanel.revalidate(); + } + + private void addHierarchyRow(Element element, int depth) { + String type = element.getAttribute("type"); + String name = element.getAttribute("name"); + Button row = new Button((name == null ? type : name) + " · " + type, + material(GuiDocument.acceptsChildren(element) ? FontImage.MATERIAL_FOLDER_OPEN : FontImage.MATERIAL_DRAG_HANDLE, + "BuilderFormIcon")); + row.setUIID(selectedElements.contains(element) ? "BuilderHierarchySelected" : "BuilderHierarchyItem"); + row.getSemantics().setIdentifier("guibuilder.hierarchy." + value(element, "name", type)) + .setLabel((name == null ? type : name) + ", " + type) + .setRole(AccessibilityRole.TREE_ITEM) + .setSelected(Boolean.valueOf(selectedElements.contains(element))); + row.getAllStyles().setPaddingLeft(depth * 3 + 1); + final boolean[] suppressAction = new boolean[1]; + row.addActionListener(e -> { + if (suppressAction[0]) { + suppressAction[0] = false; + return; + } + selectElement(element, false, "hierarchy"); + refreshEditor(); + }); + if (element != document.root()) { + row.addPointerPressedListener(e -> armHierarchyDrag(element, + componentForElement(canvasHost, element), e.getX(), e.getY(), () -> suppressAction[0] = true)); + } + row.putClientProperty("gui.element", element); + hierarchyPanel.add(row); + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) { + addHierarchyRow(((Element) child), depth + 1); + } + } + } + + private boolean dragThresholdReached(int startX, int startY, int x, int y) { + return Math.abs(x - startX) + Math.abs(y - startY) >= 6; + } + + private void armDesignerDrag(Element element, String paletteType, Component source, int x, int y, + Runnable suppressAction) { + if (document == null || (element != null && !document.containsElement(element))) { + cancelDesignerDrag(); + return; + } + designerDraggedElement = element; + designerDragDocument = document; + designerPaletteType = paletteType; + designerDragSource = source; + designerSuppressAction = suppressAction; + designerPressX = x; + designerPressY = y; + designerGrabOffsetX = element == null || source == null ? 0 : Math.max(0, x - source.getAbsoluteX()); + designerGrabOffsetY = element == null || source == null ? 0 : Math.max(0, y - source.getAbsoluteY()); + designerDragArmed = true; + designerDragActive = false; + lastDragJournalSignature = null; + recordAction("drag_armed", "component", element == null ? null : value(element, "name", "component"), + "paletteType", paletteType, "x", Integer.valueOf(x), "y", Integer.valueOf(y)); + } + + private void armHierarchyDrag(Element element, Component preview, int x, int y, Runnable suppressAction) { + armDesignerDrag(element, null, preview, x, y, suppressAction); + // The press occurs in the tree row, not inside the preview component. Using the tree's + // coordinates as a grab offset can place the guide hundreds of pixels away. + designerGrabOffsetX = 0; + designerGrabOffsetY = 0; + } + + private boolean beginGuidedResize(int x, int y) { + ResizeHit hit = resizeHitAt(x, y); + if (hit == null) return false; + Element element = hit.element; + Component source = hit.component; + int edges = hit.edges; + if (element != document.selected()) { + document.select(element); + refreshGuidedSelectionOverlay(); + } + cancelDesignerDrag(); + guidedResizeElement = element; + guidedResizeSource = source; + guidedResizeEdges = edges; + guidedResizePressX = x; + guidedResizePressY = y; + guidedResizeStartX = source.getAbsoluteX(); + guidedResizeStartY = source.getAbsoluteY(); + guidedResizeStartW = source.getWidth(); + guidedResizeStartH = source.getHeight(); + guidedResizeArmed = true; + guidedResizeActive = false; + return true; + } + + private ResizeHit resizeHitAt(int x, int y) { + if (document == null || canvasHost == null || selectedElements.isEmpty()) return null; + int slop = Math.max(10, Display.getInstance().convertToPixels(1.6f)); + Element primary = document.selected(); + ResizeHit primaryHit = resizeHit(primary, x, y, slop); + if (primaryHit != null) return primaryHit; + for (Element element : selectedElements) { + if (element == primary) continue; + ResizeHit hit = resizeHit(element, x, y, slop); + if (hit != null) return hit; + } + return null; + } + + private ResizeHit resizeHit(Element element, int x, int y, int slop) { + if (element == null || element == document.root() || !selectedElements.contains(element) + || !"LayeredLayout".equals(document.parentLayout(element))) return null; + Component source = componentForElement(canvasHost, element); + if (source == null || source.getWidth() < 1 || source.getHeight() < 1) return null; + int leftDistance = Math.abs(x - source.getAbsoluteX()); + int rightDistance = Math.abs(x - source.getAbsoluteX() - source.getWidth()); + int topDistance = Math.abs(y - source.getAbsoluteY()); + int bottomDistance = Math.abs(y - source.getAbsoluteY() - source.getHeight()); + boolean withinX = x >= source.getAbsoluteX() - slop && x <= source.getAbsoluteX() + source.getWidth() + slop; + boolean withinY = y >= source.getAbsoluteY() - slop && y <= source.getAbsoluteY() + source.getHeight() + slop; + int edges = 0; + if (withinY && leftDistance <= slop && leftDistance + 1 < rightDistance) edges |= 1; + else if (withinY && rightDistance <= slop && rightDistance + 1 < leftDistance) edges |= 2; + if (withinX && topDistance <= slop && topDistance + 1 < bottomDistance) edges |= 4; + else if (withinX && bottomDistance <= slop && bottomDistance + 1 < topDistance) edges |= 8; + return edges == 0 ? null : new ResizeHit(element, source, edges); + } + + private void updateDesignerHoverCursor(int x, int y) { + if (workspace == null) return; + Component hovered = workspace.getComponentAt(x, y); + int cursor = designerResizeCursorAt(x, y); + if (designerCursorComponent != null && designerCursorComponent != hovered) { + designerCursorComponent.setCursor(Component.DEFAULT_CURSOR); + } + if (hovered != null) hovered.setCursor(cursor); + designerCursorComponent = cursor == Component.DEFAULT_CURSOR ? null : hovered; + } + + int designerResizeCursorAt(int x, int y) { + ResizeHit hit = resizeHitAt(x, y); + return hit == null ? Component.DEFAULT_CURSOR : resizeCursor(hit.edges); + } + + private int resizeCursor(int edges) { + if ((edges & 4) != 0 && (edges & 1) != 0) return Component.NW_RESIZE_CURSOR; + if ((edges & 4) != 0 && (edges & 2) != 0) return Component.NE_RESIZE_CURSOR; + if ((edges & 8) != 0 && (edges & 1) != 0) return Component.SW_RESIZE_CURSOR; + if ((edges & 8) != 0 && (edges & 2) != 0) return Component.SE_RESIZE_CURSOR; + if ((edges & 1) != 0) return Component.W_RESIZE_CURSOR; + if ((edges & 2) != 0) return Component.E_RESIZE_CURSOR; + if ((edges & 4) != 0) return Component.N_RESIZE_CURSOR; + if ((edges & 8) != 0) return Component.S_RESIZE_CURSOR; + return Component.DEFAULT_CURSOR; + } + + private void updateGuidedResize(int x, int y) { + if (!guidedResizeArmed) return; + if (!guidedResizeActive && !dragThresholdReached(guidedResizePressX, guidedResizePressY, x, y)) return; + guidedResizeActive = true; + activeResizePlan = planGuidedResize(x, y); + if (activeResizePlan != null && dragGuideOverlay != null) { + dragGuideOverlay.showResize((Container) guidedResizeSource.getParent(), guidedResizeSource, + activeResizePlan.x, activeResizePlan.y, activeResizePlan.width, activeResizePlan.height, + activeResizePlan.description); + showGuidedResizeSimulation(guidedResizeElement, (Container) guidedResizeSource.getParent(), + guidedResizeSource, activeResizePlan, guidedResizeEdges); + setStatus(activeResizePlan.description); + } + } + + private ResizePlan planGuidedResize(int pointerX, int pointerY) { + if (guidedResizeSource == null || guidedResizeSource.getParent() == null) return null; + Container parent = guidedResizeSource.getParent(); + int dx = pointerX - guidedResizePressX; + int dy = pointerY - guidedResizePressY; + int left = guidedResizeStartX; + int right = guidedResizeStartX + guidedResizeStartW; + int top = guidedResizeStartY; + int bottom = guidedResizeStartY + guidedResizeStartH; + if ((guidedResizeEdges & 1) != 0) left += dx; + if ((guidedResizeEdges & 2) != 0) right += dx; + if ((guidedResizeEdges & 4) != 0) top += dy; + if ((guidedResizeEdges & 8) != 0) bottom += dy; + int minWidth = Math.max(16, Display.getInstance().convertToPixels(2)); + int minHeight = Math.max(16, Display.getInstance().convertToPixels(2)); + if (right - left < minWidth) { + if ((guidedResizeEdges & 1) != 0) left = right - minWidth; else right = left + minWidth; + } + if (bottom - top < minHeight) { + if ((guidedResizeEdges & 4) != 0) top = bottom - minHeight; else bottom = top + minHeight; + } + ResizePlan plan = new ResizePlan(left, top, right - left, bottom - top); + int threshold = Math.max(8, Display.getInstance().convertToPixels(1.5f)); + int bestHorizontal = threshold + 1; + int bestVertical = threshold + 1; + for (int i = 0; i < parent.getComponentCount(); i++) { + Component sibling = parent.getComponentAt(i); + if (sibling == guidedResizeSource || sibling.getClientProperty("gui.element") == null) continue; + Element siblingElement = (Element) sibling.getClientProperty("gui.element"); + if ((guidedResizeEdges & 2) != 0) { + int distance = Math.abs((plan.x + plan.width) - (plan.x + sibling.getWidth())); + if (distance < bestHorizontal) { + plan.width = sibling.getWidth(); plan.matchWidth = siblingElement; bestHorizontal = distance; + plan.horizontalDescription = "same width as " + value(siblingElement, "name", "component"); + } + } else if ((guidedResizeEdges & 1) != 0) { + int candidate = plan.x + plan.width - sibling.getWidth(); + int distance = Math.abs(plan.x - candidate); + if (distance < bestHorizontal) { + plan.x = candidate; plan.width = sibling.getWidth(); plan.matchWidth = siblingElement; bestHorizontal = distance; + plan.horizontalDescription = "same width as " + value(siblingElement, "name", "component"); + } + } + if ((guidedResizeEdges & 8) != 0) { + int distance = Math.abs((plan.y + plan.height) - (plan.y + sibling.getHeight())); + if (distance < bestVertical) { + plan.height = sibling.getHeight(); plan.matchHeight = siblingElement; bestVertical = distance; + plan.verticalDescription = "same height as " + value(siblingElement, "name", "component"); + } + } else if ((guidedResizeEdges & 4) != 0) { + int candidate = plan.y + plan.height - sibling.getHeight(); + int distance = Math.abs(plan.y - candidate); + if (distance < bestVertical) { + plan.y = candidate; plan.height = sibling.getHeight(); plan.matchHeight = siblingElement; bestVertical = distance; + plan.verticalDescription = "same height as " + value(siblingElement, "name", "component"); + } + } + } + int parentLeft = contentX(parent); + int parentTop = contentY(parent); + int parentRight = parentLeft + contentWidth(parent); + int parentBottom = parentTop + contentHeight(parent); + if ((guidedResizeEdges & 1) != 0 && Math.abs(plan.x - parentLeft) <= threshold) { + plan.width += plan.x - parentLeft; plan.x = parentLeft; plan.matchWidth = null; plan.horizontalDescription = "dock left"; + } + if ((guidedResizeEdges & 2) != 0 && Math.abs(plan.x + plan.width - parentRight) <= threshold) { + plan.width = parentRight - plan.x; plan.matchWidth = null; plan.horizontalDescription = "dock right"; + } + if ((guidedResizeEdges & 4) != 0 && Math.abs(plan.y - parentTop) <= threshold) { + plan.height += plan.y - parentTop; plan.y = parentTop; plan.matchHeight = null; plan.verticalDescription = "dock top"; + } + if ((guidedResizeEdges & 8) != 0 && Math.abs(plan.y + plan.height - parentBottom) <= threshold) { + plan.height = parentBottom - plan.y; plan.matchHeight = null; plan.verticalDescription = "dock bottom"; + } + plan.description = plan.horizontalDescription == null ? plan.verticalDescription + : plan.verticalDescription == null ? plan.horizontalDescription + : plan.horizontalDescription + " • " + plan.verticalDescription; + if (plan.description == null) plan.description = plan.width + " × " + plan.height; + return plan; + } + + private void finishGuidedResize(int x, int y) { + if (!guidedResizeArmed) return; + boolean active = guidedResizeActive; + ResizePlan plan = active ? planGuidedResize(x, y) : null; + Element element = guidedResizeElement; + Component source = guidedResizeSource; + int edges = guidedResizeEdges; + guidedResizeArmed = guidedResizeActive = false; + guidedResizeElement = null; + guidedResizeSource = null; + activeResizePlan = null; + if (!active || plan == null || element == null || !document.containsElement(element) + || source == null || source.getParent() == null) { + refreshGuidedSelectionOverlay(); + return; + } + Container parent = source.getParent(); + commitGuidedSelectionResize(element, parent, plan, edges); + refreshEditor(); + setStatus(selectedElements.size() > 1 + ? "Resized " + selectedElements.size() + " components using reference " + + value(element, "name", "component") + " at " + plan.width + " × " + plan.height + : "Resized " + value(element, "name", "component") + " to " + plan.width + " × " + plan.height); + } + + void commitGuidedResize(Element element, Container parent, ResizePlan plan, int edges) { + Component preview = componentForElement(canvasHost, element); + commitGuidedResize(document, element, parent, preview, plan, edges); + } + + void commitGuidedSelectionResize(Element element, Container parent, ResizePlan plan, int edges) { + boolean group = selectedElements.size() > 1 && selectedElements.contains(element) + && selectedElementsShareGuidedParent(); + ResizePlan referencePlan = group ? fixedReferenceResizePlan(plan, edges) : plan; + document.beginTransaction(); + try { + commitGuidedResize(document, element, parent, componentForElement(canvasHost, element), referencePlan, edges); + if (group) { + for (Element selected : new ArrayList(selectedElements)) { + if (selected == element) continue; + Component preview = componentForElement(canvasHost, selected); + if ((edges & 3) != 0) { + applyLayeredRelationship(document, selected, element, "matchWidth", preview); + } + if ((edges & 12) != 0) { + applyLayeredRelationship(document, selected, element, "matchHeight", preview); + } + } + } + document.select(element); + } finally { + document.endTransaction(); + } + } + + private ResizePlan fixedReferenceResizePlan(ResizePlan plan, int edges) { + ResizePlan fixed = new ResizePlan(plan.x, plan.y, plan.width, plan.height); + fixed.horizontalDescription = plan.horizontalDescription; + fixed.verticalDescription = plan.verticalDescription; + fixed.description = plan.description; + fixed.matchWidth = (edges & 3) == 0 ? plan.matchWidth : null; + fixed.matchHeight = (edges & 12) == 0 ? plan.matchHeight : null; + return fixed; + } + + private void commitGuidedResize(GuiDocument targetDocument, Element element, Container parent, + Component preview, ResizePlan plan, int edges) { + targetDocument.select(element); + int marginLeft = preview == null ? 0 : preview.getStyle().getMarginLeftNoRTL(); + int marginTop = preview == null ? 0 : preview.getStyle().getMarginTop(); + String[] insets = GuidedLayoutSupport.insetValues(element); + String[] refs = GuidedLayoutSupport.referenceNames(element); + String[] positions = GuidedLayoutSupport.referencePositions(element); + targetDocument.beginTransaction(); + try { + if ((edges & 3) != 0) { + String explicitMatch = value(element, "guidedMatchWidth", ""); + boolean keepExplicitMatch = GuidedLayoutSupport.MATCH.equals(GuidedLayoutSupport.horizontalPolicy(element)) + && plan.matchWidth != null && explicitMatch.equals(value(plan.matchWidth, "name", "")); + if (keepExplicitMatch) { + applyMatchedWidth(plan.matchWidth, insets, refs, positions, plan.x, plan.width); + targetDocument.setAttribute("guidedMatchWidth", explicitMatch); + targetDocument.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.MATCH); + } else { + insets[3] = Math.max(0, plan.x - contentX(parent) - marginLeft) + "px"; + insets[1] = "auto"; refs[3] = refs[1] = "-"; positions[3] = positions[1] = "0"; + targetDocument.setAttribute("guidedPreferredWidth", String.valueOf(plan.width)); + targetDocument.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.FIXED); + targetDocument.setAttribute("guidedMatchWidth", null); + } + targetDocument.setAttribute("guidedHorizontalAnchor", null); + } + if ((edges & 12) != 0) { + String explicitMatch = value(element, "guidedMatchHeight", ""); + boolean keepExplicitMatch = GuidedLayoutSupport.MATCH.equals(GuidedLayoutSupport.verticalPolicy(element)) + && plan.matchHeight != null && explicitMatch.equals(value(plan.matchHeight, "name", "")); + if (keepExplicitMatch) { + applyMatchedHeight(plan.matchHeight, insets, refs, positions, plan.y, plan.height); + targetDocument.setAttribute("guidedMatchHeight", explicitMatch); + targetDocument.setAttribute("guidedVerticalSize", GuidedLayoutSupport.MATCH); + } else { + insets[0] = Math.max(0, plan.y - contentY(parent) - marginTop) + "px"; + insets[2] = "auto"; refs[0] = refs[2] = "-"; positions[0] = positions[2] = "0"; + targetDocument.setAttribute("guidedPreferredHeight", String.valueOf(plan.height)); + targetDocument.setAttribute("guidedVerticalSize", GuidedLayoutSupport.FIXED); + targetDocument.setAttribute("guidedMatchHeight", null); + } + targetDocument.setAttribute("guidedVerticalAnchor", null); + } + targetDocument.setAttribute("layeredInsets", GuidedLayoutSupport.joinInsets(insets[0], insets[1], insets[2], insets[3])); + targetDocument.setAttribute("guidedReferences", GuidedLayoutSupport.joinReferences(refs[0], refs[1], refs[2], refs[3])); + targetDocument.setAttribute("guidedReferencePositions", GuidedLayoutSupport.joinPositions(positions[0], positions[1], positions[2], positions[3])); + } finally { + targetDocument.endTransaction(); + } + } + + private void updateDesignerDrag(int x, int y) { + if (!designerDragArmed) return; + if (!designerDragActive && dragThresholdReached(designerPressX, designerPressY, x, y)) { + designerDragActive = true; + recordAction("drag_started", "component", designerDraggedElement == null ? null + : value(designerDraggedElement, "name", "component"), "paletteType", designerPaletteType); + if (designerSuppressAction != null) designerSuppressAction.run(); + finishInlineEditor(); + hideDropGuide(); + } + if (designerDragActive) { + autoScrollDuringDrag(x, y); + showDropGuide(designerDraggedElement, activePreviewElementAt(x, y), designerDragSource, x, y); + } + } + + private void finishDesignerDrag(int x, int y) { + if (!designerDragArmed) { + if (activeDropPlan != null || boxDropSpacer != null) hideDropGuide(); + return; + } + Element element = designerDraggedElement; + GuiDocument dragDocument = designerDragDocument; + String paletteType = designerPaletteType; + boolean activeDrag = designerDragActive; + Component dragSource = designerDragSource; + DropPlan releasePlan = activeDrag && element != null && dragDocument == document + ? planDrop(element, designerDropTargetAt(element, x, y), dragSource, x, y) : null; + designerDraggedElement = null; + designerDragDocument = null; + designerPaletteType = null; + designerDragSource = null; + designerSuppressAction = null; + designerDragArmed = false; + designerDragActive = false; + if (!activeDrag) { + recordAction("drag_cancelled", "reason", "released_before_threshold"); + hideDropGuide(); + return; + } + recordAction("drag_released", "component", element == null ? null : value(element, "name", "component"), + "x", Integer.valueOf(x), "y", Integer.valueOf(y)); + if (dragDocument != document || (element != null && !document.containsElement(element))) { + hideDropGuide(); + setStatus("Drop cancelled because the active form changed"); + return; + } + if (paletteType != null) addComponentAt(paletteType, x, y); + else if (element != null) handleComponentDrop(element, releasePlan, x, y); + } + + private void handleComponentDrop(Element dragged, DropPlan releasePlan, int x, int y) { + try { + if (!document.containsElement(dragged)) { + setStatus("Drop cancelled — component is not part of the active form"); + return; + } + DropPlan plan = releasePlan; + if (plan == null || !plan.valid) { + recordAction("drop_rejected", "component", value(dragged, "name", "component"), + "reason", plan == null ? "outside_form" : plan.message); + setStatus(plan == null ? "Drop outside the form — no changes made" : plan.message); + return; + } + boolean grouped = selectedElements.size() > 1 && selectedElements.contains(dragged) + && applyGroupedGuidedDrop(dragged, plan); + if (grouped || applyDropPlan(dragged, plan, x, y)) { + recordAction("drop_committed", "component", value(dragged, "name", "component"), + "groupCount", Integer.valueOf(grouped ? selectedElements.size() : 1), + "layout", plan.layout, "constraint", plan.constraint, "target", + value(plan.target, "name", value(plan.target, "type", "component")), + "snap", plan.snapDescription, + "horizontalKind", plan.horizontalSnap == null ? null : plan.horizontalSnap.kind, + "horizontalReference", snapReferenceName(plan.horizontalSnap), + "verticalKind", plan.verticalSnap == null ? null : plan.verticalSnap.kind, + "verticalReference", snapReferenceName(plan.verticalSnap)); + setStatus(grouped ? "Moved " + selectedElements.size() + " components as a group" + : "Moved " + value(dragged, "name", value(dragged, "type", "component")) + + (plan.constraint == null ? "" : " to " + plan.constraint)); + scheduleDesignerRefresh(); + } + } catch (Throwable ex) { + Log.e(ex); + recordAction("drop_error", "component", value(dragged, "name", "component"), "message", ex.getMessage()); + setStatus("Drop failed safely — " + (ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage())); + ToastBar.showErrorMessage("Unable to complete drop; the form was left editable"); + } finally { + hideDropGuide(); + Component preview = componentForElement(canvasHost, dragged); + if (preview != null) preview.setVisible(true); + if (workspace != null) workspace.revalidate(); + } + } + + boolean applyGroupedGuidedDrop(Element dragged, DropPlan plan) { + if (document == null || plan == null || !plan.valid || !"LayeredLayout".equals(plan.layout) + || !selectedElementsShareGuidedParent()) return false; + GroupMoveGeometry geometry = groupedMoveGeometry(dragged, plan); + if (geometry == null) return false; + Element primary = document.selected(); + Set selectedNames = new LinkedHashSet<>(); + for (Element element : selectedElements) selectedNames.add(value(element, "name", "")); + document.beginTransaction(); + try { + // Components outside the group must not be pulled along by a selected anchor. Rebase + // them at their current rectangle before moving the group, while retaining every + // relationship whose two endpoints are both selected. + for (Element selected : selectedElements) { + String selectedName = value(selected, "name", ""); + for (Element candidate : document.components()) { + String candidateName = value(candidate, "name", ""); + if (selectedNames.contains(candidateName)) continue; + if (incomingReferenceNames(document, candidateName).contains(selectedName)) { + freezeDependencyAtCurrentBounds(document, candidateName, selectedName); + } + } + } + for (Map.Entry entry : geometry.previews.entrySet()) { + Component preview = entry.getValue(); + translateGuidedGroupElement(entry.getKey(), geometry.parent, preview, + geometry.dx, geometry.dy, selectedNames); + } + document.select(primary); + } finally { + document.endTransaction(); + } + return true; + } + + private GroupMoveGeometry groupedMoveGeometry(Element dragged, DropPlan plan) { + if (document == null || canvasHost == null || dragged == null || plan == null + || !plan.valid || !"LayeredLayout".equals(plan.layout) + || !selectedElements.contains(dragged) || !selectedElementsShareGuidedParent()) return null; + Element parentElement = document.parentOf(dragged); + if (parentElement != plan.parent) return null; + Component parentPreview = componentForElement(canvasHost, parentElement); + Component draggedPreview = componentForElement(canvasHost, dragged); + if (!(parentPreview instanceof Container) || draggedPreview == null) return null; + Map previews = new LinkedHashMap<>(); + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE; + for (Element element : selectedElements) { + if (document.parentOf(element) != parentElement) return null; + Component preview = componentForElement(canvasHost, element); + if (preview == null) return null; + previews.put(element, preview); + minX = Math.min(minX, preview.getAbsoluteX()); + minY = Math.min(minY, preview.getAbsoluteY()); + maxX = Math.max(maxX, preview.getAbsoluteX() + preview.getWidth()); + maxY = Math.max(maxY, preview.getAbsoluteY() + preview.getHeight()); + } + int dx = plan.snapX - draggedPreview.getAbsoluteX(); + int dy = plan.snapY - draggedPreview.getAbsoluteY(); + int contentLeft = contentX(((Container) parentPreview)); + int contentTop = contentY(((Container) parentPreview)); + int contentRight = contentLeft + contentWidth(((Container) parentPreview)); + int contentBottom = contentTop + contentHeight(((Container) parentPreview)); + dx = Math.max(contentLeft - minX, Math.min(dx, contentRight - maxX)); + dy = Math.max(contentTop - minY, Math.min(dy, contentBottom - maxY)); + return new GroupMoveGeometry(((Container) parentPreview), previews, dx, dy); + } + + GuidedSimulation simulateGroupedGuidedDrop(Element dragged, DropPlan plan) { + GroupMoveGeometry geometry = groupedMoveGeometry(dragged, plan); + if (geometry == null || geometry.previews.size() < 2) return null; + List items = new ArrayList<>(); + Set changed = new LinkedHashSet<>(); + String activeName = value(dragged, "name", "component"); + for (Map.Entry entry : geometry.previews.entrySet()) { + Component preview = entry.getValue(); + String name = value(entry.getKey(), "name", value(entry.getKey(), "type", "component")); + changed.add(name); + items.add(new DragGuideOverlay.GlassItem(name, + preview.getAbsoluteX(), preview.getAbsoluteY(), preview.getWidth(), preview.getHeight(), + preview.getAbsoluteX() + geometry.dx, preview.getAbsoluteY() + geometry.dy, + preview.getWidth(), preview.getHeight(), name.equals(activeName))); + } + return new GuidedSimulation(document, items, new ArrayList<>(), + "Preview: move " + items.size() + " components as a group", changed); + } + + private void translateGuidedGroupElement(Element element, Container parent, Component component, + int dx, int dy, Set selectedNames) { + String[] insets = GuidedLayoutSupport.insetValues(element); + String[] refs = GuidedLayoutSupport.referenceNames(element); + String[] positions = GuidedLayoutSupport.referencePositions(element); + boolean leftInternal = selectedNames.contains(refs[3]); + boolean rightInternal = selectedNames.contains(refs[1]); + boolean topInternal = selectedNames.contains(refs[0]); + boolean bottomInternal = selectedNames.contains(refs[2]); + document.select(element); + + if (!leftInternal && !rightInternal) { + insets[3] = Math.max(0, component.getAbsoluteX() + dx - contentX(parent) + - component.getStyle().getMarginLeftNoRTL()) + "px"; + insets[1] = "auto"; + refs[3] = refs[1] = "-"; + positions[3] = positions[1] = "0"; + document.setAttribute("guidedHorizontalAnchor", null); + } else { + if (!leftInternal) { insets[3] = "auto"; refs[3] = "-"; positions[3] = "0"; } + if (!rightInternal) { insets[1] = "auto"; refs[1] = "-"; positions[1] = "0"; } + } + if (!topInternal && !bottomInternal) { + insets[0] = Math.max(0, component.getAbsoluteY() + dy - contentY(parent) + - component.getStyle().getMarginTop()) + "px"; + insets[2] = "auto"; + refs[0] = refs[2] = "-"; + positions[0] = positions[2] = "0"; + document.setAttribute("guidedVerticalAnchor", null); + } else { + if (!topInternal) { insets[0] = "auto"; refs[0] = "-"; positions[0] = "0"; } + if (!bottomInternal) { insets[2] = "auto"; refs[2] = "-"; positions[2] = "0"; } + } + + // Fill needs opposing constraints. If a group move removes one external edge, preserve + // the rendered dimension instead of allowing the component to resize unexpectedly. + if (GuidedLayoutSupport.FILL.equals(GuidedLayoutSupport.horizontalPolicy(element)) + && !(leftInternal && rightInternal)) { + document.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.FIXED); + document.setAttribute("guidedPreferredWidth", String.valueOf(Math.max(1, component.getWidth()))); + } + if (GuidedLayoutSupport.FILL.equals(GuidedLayoutSupport.verticalPolicy(element)) + && !(topInternal && bottomInternal)) { + document.setAttribute("guidedVerticalSize", GuidedLayoutSupport.FIXED); + document.setAttribute("guidedPreferredHeight", String.valueOf(Math.max(1, component.getHeight()))); + } + document.setAttribute("layeredInsets", GuidedLayoutSupport.joinInsets(insets[0], insets[1], insets[2], insets[3])); + document.setAttribute("guidedReferences", GuidedLayoutSupport.joinReferences(refs[0], refs[1], refs[2], refs[3])); + document.setAttribute("guidedReferencePositions", GuidedLayoutSupport.joinPositions( + positions[0], positions[1], positions[2], positions[3])); + } + + private void addComponentAt(String type, int x, int y) { + Element previous = document.selected(); + Element added = null; + // A rejected drop has to leave the document exactly as it was. The candidate is inserted + // under the current selection before the plan is known, and for a TableLayout parent that + // insertion assigns a cell and can grow tableLayoutRows -- undoing only the child left the + // table enlarged and the document dirty for a drop that never happened. + String tableRowsBefore = null; + Element candidateParent = GuiDocument.acceptsChildren(previous) ? previous : document.parentOf(previous); + if (candidateParent != null && "TableLayout".equals(candidateParent.getAttribute("layout"))) { + tableRowsBefore = candidateParent.getAttribute("tableLayoutRows"); + } + try { + added = document.addComponent(type); + if (added == null) { + ToastBar.showErrorMessage("That container is a BorderLayout and all five regions are taken"); + setStatus("Nothing added: the drop target is a full BorderLayout"); + return; + } + Element target = activePreviewElementAt(x, y); + Component palettePreview = ComponentPreviewFactory.create(added, null, new ComponentPreviewFactory.SelectionHandler() { + @Override public void selected(Element element) { } + @Override public void dragPressed(Element element, Component source, int px, int py) { } + @Override public boolean isDragActive() { return false; } + @Override public void editContent(Element element) { } + }); + DropPlan plan = planDrop(added, target, palettePreview, x, y); + if (plan == null || !plan.valid) { + document.select(added); + document.deleteSelected(); + restoreTableRows(candidateParent, tableRowsBefore); + document.select(previous); + setStatus(plan == null ? "Drop inside the form to add " + type : plan.message); + return; + } + if (applyDropPlan(added, plan, x, y)) { + setStatus("Added " + type + (plan.constraint == null ? "" : " to " + plan.constraint)); + scheduleDesignerRefresh(); + } + } catch (Throwable ex) { + Log.e(ex); + if (added != null && document.parentOf(added) != null) { + document.select(added); + document.deleteSelected(); + restoreTableRows(candidateParent, tableRowsBefore); + document.select(previous); + } + setStatus("Drop failed safely — " + (ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage())); + } finally { + hideDropGuide(); + if (workspace != null) workspace.revalidate(); + } + } + + /** + * Puts a table's declared row count back after a rejected drop. + * + * @param parent the table the candidate was inserted into, or null when it was not a table + * @param rows the value the attribute held before, possibly null + */ + private void restoreTableRows(Element parent, String rows) { + if (parent == null || !"TableLayout".equals(parent.getAttribute("layout"))) return; + document.setAttribute(parent, "tableLayoutRows", rows); + } + + private boolean dropAfter(Element target, String layout, int x, int y) { + if (target == null || GuiDocument.acceptsChildren(target)) return false; + Component preview = componentForElement(canvasHost, target); + if (preview == null) return false; + Element parent = document.parentOf(target); + return "FlowLayout".equals(layout) || "GridLayout".equals(layout) + ? x >= preview.getAbsoluteX() + preview.getWidth() / 2 + : "X".equals(value(parent, "boxLayoutAxis", "Y")) + ? x >= preview.getAbsoluteX() + preview.getWidth() / 2 + : y >= preview.getAbsoluteY() + preview.getHeight() / 2; + } + + private void showDropGuide(Element dragged, Element target, Component source, int x, int y) { + target = normalizeDesignerDropTarget(dragged, target); + dropGuideTarget = target; + DropPlan plan = planDrop(dragged, target, source, x, y); + if (plan == null) { + hideDropGuide(); + return; + } + activeDropPlan = plan; + String journalSignature = value(dragged, "name", designerPaletteType == null ? "component" : designerPaletteType) + + "|" + value(plan.target, "name", value(plan.target, "type", "component")) + + "|" + plan.layout + "|" + plan.constraint + "|" + plan.valid + "|" + plan.snapDescription; + if (!journalSignature.equals(lastDragJournalSignature)) { + lastDragJournalSignature = journalSignature; + recordAction("drag_preview", "component", value(dragged, "name", designerPaletteType), + "target", value(plan.target, "name", value(plan.target, "type", "component")), + "layout", plan.layout, "constraint", plan.constraint, "valid", Boolean.valueOf(plan.valid), + "description", plan.snapDescription, + "horizontalKind", plan.horizontalSnap == null ? null : plan.horizontalSnap.kind, + "horizontalReference", snapReferenceName(plan.horizontalSnap), + "verticalKind", plan.verticalSnap == null ? null : plan.verticalSnap.kind, + "verticalReference", snapReferenceName(plan.verticalSnap)); + } + Component parentPreview = componentForElement(canvasHost, plan.parent); + Element guideElement = plan.occupied == null ? target : plan.occupied; + Component targetPreview = componentForElement(canvasHost, guideElement); + if (dragGuideOverlay != null && parentPreview != null) { + dragGuideOverlay.showGuide(plan.layout, value(plan.parent, "boxLayoutAxis", "Y"), plan.constraint, + plan.valid, parentPreview, targetPreview, source, x, y, + plan.snapX, plan.snapY, plan.snapW, plan.snapH, plan.snapDescription); + if (dragged != null && plan.valid && "LayeredLayout".equals(plan.layout)) { + GuidedSimulation grouped = simulateGroupedGuidedDrop(dragged, plan); + if (grouped != null) showSimulation(grouped); + else showGuidedDropSimulation(dragged, plan, (Container) parentPreview, source); + } else { + dragGuideOverlay.clearSimulation(); + } + } + if ("BoxLayout".equals(plan.layout) && plan.valid && parentPreview instanceof Container) { + showBoxDropSpacer(((Container) parentPreview), targetPreview, plan.after, + "X".equals(value(plan.parent, "boxLayoutAxis", "Y"))); + } else { + clearBoxDropSpacer(); + } + setStatus(plan.message); + } + + private void hideDropGuide() { + dropGuideTarget = null; + activeDropPlan = null; + if (dragGuideOverlay != null) dragGuideOverlay.hideGuide(); + clearBoxDropSpacer(); + } + + private void scheduleDesignerRefresh() { + if (refreshPending) return; + refreshPending = true; + Display.getInstance().callSerially(() -> { + refreshPending = false; + refreshEditor(); + }); + } + + private void showBoxDropSpacer(Container parent, Component target, boolean after, boolean horizontal) { + int index = target != null && target.getParent() == parent ? parent.getComponentIndex(target) : parent.getComponentCount(); + if (after && index < parent.getComponentCount()) index++; + if (boxDropParent == parent && boxDropIndex == index && boxDropSpacer != null) return; + if (boxDropParent == parent && boxDropSpacer != null && boxDropSpacer.getParent() == parent) { + boxDropIndex = Math.max(0, Math.min(index, parent.getComponentCount() - 1)); + parent.removeComponent(boxDropSpacer); + parent.addComponent(Math.min(boxDropIndex, parent.getComponentCount()), boxDropSpacer); + parent.revalidate(); + return; + } + clearBoxDropSpacer(); + final int spacerWidth = horizontal ? Math.max(12, Display.getInstance().convertToPixels(2)) : 1; + final int spacerHeight = horizontal ? 1 : Math.max(12, Display.getInstance().convertToPixels(2)); + Label spacer = new Label(" ", "BuilderDropSpacer") { + @Override protected Dimension calcPreferredSize() { + return new Dimension(spacerWidth, spacerHeight); + } + }; + spacer.setShowEvenIfBlank(true); + boxDropSpacer = spacer; + boxDropTargetElement = target == null ? null : (Element) target.getClientProperty("gui.element"); + spacer.putClientProperty("gui.dropTargetElement", boxDropTargetElement); + boxDropParent = parent; + boxDropIndex = Math.max(0, Math.min(index, parent.getComponentCount())); + parent.addComponent(boxDropIndex, spacer); + // revalidate, never animateLayout: a layout animation captures the preview tree and + // re-applies that captured state when it finishes. The drop commits and rebuilds the + // canvas well inside the animation's window, so the animation then restored the + // pre-drop preview over the new one -- the model moved, the canvas did not, and the + // spacer stayed behind. Nested containers made it worse because each level animated. + parent.revalidate(); + } + + private void clearBoxDropSpacer() { + if (boxDropSpacer != null && boxDropSpacer.getParent() != null) { + Container parent = boxDropSpacer.getParent(); + parent.removeComponent(boxDropSpacer); + parent.revalidate(); + } + boxDropSpacer = null; + boxDropTargetElement = null; + boxDropParent = null; + boxDropIndex = -1; + } + + DropPlan planDrop(Element dragged, Element target, Component source, int x, int y) { + if (document == null || target == null || target == dragged + || !document.containsElement(target) + || (dragged != null && !document.containsElement(dragged))) return null; + Element parent = GuiDocument.acceptsChildren(target) ? target : document.parentOf(target); + if (parent == null || parent == dragged) return null; + String layout = value(parent, "layout", "BoxLayout"); + DropPlan plan = new DropPlan(); + plan.document = document; + plan.target = target; + plan.parent = parent; + plan.layout = layout; + plan.snapX = x - (dragged == null ? 0 : designerGrabOffsetX); + plan.snapY = y - (dragged == null ? 0 : designerGrabOffsetY); + plan.after = dropAfter(target, layout, x, y); + plan.valid = true; + plan.message = "Drop into " + value(parent, "name", value(parent, "type", "container")); + placementAdapter(layout).plan(plan, dragged, target, source, x, y); + return plan; + } + + private PlacementAdapter placementAdapter(String layout) { + if ("BorderLayout".equals(layout)) return new BorderPlacementAdapter(); + if ("LayeredLayout".equals(layout)) return new LayeredPlacementAdapter(); + if ("TableLayout".equals(layout)) return new TablePlacementAdapter(); + if ("GridLayout".equals(layout)) return new GridPlacementAdapter(); + if ("FlowLayout".equals(layout)) return new FlowPlacementAdapter(); + return new BoxPlacementAdapter(); + } + + String placementAdapterName(String layout) { return placementAdapter(layout).getClass().getSimpleName(); } + + private interface PlacementAdapter { + void plan(DropPlan plan, Element dragged, Element target, Component source, int x, int y); + } + + private final class BorderPlacementAdapter implements PlacementAdapter { + public void plan(DropPlan plan, Element dragged, Element target, Component source, int x, int y) { + Component parentPreview = componentForElement(canvasHost, plan.parent); + plan.constraint = borderRegionAt(plan.parent, dragged, parentPreview, source, x, y); + plan.occupied = GuiDocument.childAtBorderConstraint(plan.parent, plan.constraint, dragged); + if (plan.occupied != null && (dragged == null || source == null)) { + plan.valid = false; + plan.message = plan.constraint + " is occupied by " + + value(plan.occupied, "name", value(plan.occupied, "type", "component")); + } else if (plan.occupied != null) { + plan.message = "Swap " + value(dragged, "name", "component") + " with " + + value(plan.occupied, "name", "component") + " in " + plan.constraint; + } else plan.message = "Drop into the " + plan.constraint + " region"; + } + } + + private final class LayeredPlacementAdapter implements PlacementAdapter { + public void plan(DropPlan plan, Element dragged, Element target, Component source, int x, int y) { + resolveLayeredSnap(plan, source, plan.snapX, plan.snapY); + plan.message = plan.snapDescription == null ? "Place freely in LayeredLayout" : "Snap: " + plan.snapDescription; + } + } + + private class BoxPlacementAdapter implements PlacementAdapter { + public void plan(DropPlan plan, Element dragged, Element target, Component source, int x, int y) { + plan.message = "Insert " + (plan.after ? "after " : "before ") + value(target, "name", "component"); + } + } + + private final class FlowPlacementAdapter extends BoxPlacementAdapter { } + private final class GridPlacementAdapter extends BoxPlacementAdapter { } + + /** + * A table drop is a placement into one addressed cell, not an insertion into a sequence. It + * therefore behaves like BorderLayout rather than BoxLayout: the pointer picks the cell, and a + * cell that is already taken swaps rather than pushing everything along. + */ + private final class TablePlacementAdapter implements PlacementAdapter { + public void plan(DropPlan plan, Element dragged, Element target, Component source, int x, int y) { + Component parentPreview = componentForElement(canvasHost, plan.parent); + plan.tableCell = tableCellAt(plan.parent, parentPreview, x, y); + plan.occupied = childAtTableCell(plan.parent, plan.tableCell[0], plan.tableCell[1], dragged); + String cell = "row " + plan.tableCell[0] + ", column " + plan.tableCell[1]; + if (plan.occupied == null) { + plan.message = "Place in " + cell; + } else if (dragged == null) { + plan.valid = false; + plan.message = cell + " is occupied by " + + value(plan.occupied, "name", value(plan.occupied, "type", "component")); + } else { + plan.message = "Swap with " + value(plan.occupied, "name", "component") + " in " + cell; + } + } + } + + private String borderRegionAt(Element parent, Element dragged, Component parentPreview, + Component source, int x, int y) { + if (parentPreview == null || parentPreview.getWidth() < 1 || parentPreview.getHeight() < 1 + || x < parentPreview.getAbsoluteX() || x > parentPreview.getAbsoluteX() + parentPreview.getWidth() + || y < parentPreview.getAbsoluteY() || y > parentPreview.getAbsoluteY() + parentPreview.getHeight()) { + return firstAvailableBorderConstraint(parent, dragged); + } + int px = parentPreview.getAbsoluteX(); + int py = parentPreview.getAbsoluteY(); + int pw = parentPreview.getWidth(); + int ph = parentPreview.getHeight(); + int sourceW = source == null || source.getWidth() < 1 ? Math.max(48, pw / 5) : source.getWidth(); + int sourceH = source == null || source.getHeight() < 1 ? Math.max(32, ph / 8) : source.getHeight(); + + // CENTER normally covers every pixel left over by BorderLayout. If component hit-testing + // runs first, that makes empty EAST/WEST (and often NORTH/SOUTH) impossible to reach. + // Resolve intentional edge bands first, then use the component under the pointer for the + // central area. The bands scale with the dragged component but remain generous enough to + // use with a mouse. + String edgeRegion = borderEdgeRegion(px, py, pw, ph, sourceW, sourceH, x, y); + if (edgeRegion != null) return edgeRegion; + + for (int i = 0; i < parent.getNumChildren(); i++) { + Object childValue = parent.getChildAt(i); + if (!(childValue instanceof Element) || ((Element) childValue) == dragged || !"component".equals(((Element) childValue).getTagName())) continue; + Component childPreview = componentForElement(canvasHost, ((Element) childValue)); + if (childPreview != null && x >= childPreview.getAbsoluteX() && x <= childPreview.getAbsoluteX() + childPreview.getWidth() + && y >= childPreview.getAbsoluteY() && y <= childPreview.getAbsoluteY() + childPreview.getHeight()) { + return GuiDocument.effectiveBorderConstraint(parent, ((Element) childValue)); + } + } + return "Center"; + } + + static String borderEdgeRegion(int px, int py, int pw, int ph, int sourceW, int sourceH, int x, int y) { + int horizontalBand = Math.min(Math.max(Math.max(64, sourceW), pw * 30 / 100), Math.max(1, pw * 40 / 100)); + int verticalBand = Math.min(Math.max(Math.max(44, sourceH), ph * 22 / 100), Math.max(1, ph * 35 / 100)); + int north = y - py; + int south = py + ph - y; + int west = x - px; + int east = px + pw - x; + String region = null; + double score = Double.MAX_VALUE; + if (north >= 0 && north <= verticalBand) { region = "North"; score = (double) north / verticalBand; } + if (south >= 0 && south <= verticalBand && (double) south / verticalBand < score) { + region = "South"; score = (double) south / verticalBand; + } + if (west >= 0 && west <= horizontalBand && (double) west / horizontalBand < score) { + region = "West"; score = (double) west / horizontalBand; + } + if (east >= 0 && east <= horizontalBand && (double) east / horizontalBand < score) region = "East"; + return region; + } + + private void resolveLayeredSnap(DropPlan plan, Component source, int x, int y) { + Component parentComponent = componentForElement(canvasHost, plan.parent); + if (!(parentComponent instanceof Container)) return; + int contentW = contentWidth(((Container) parentComponent)); + int contentH = contentHeight(((Container) parentComponent)); + int width = source == null ? Math.max(80, contentW / 6) + : source.getWidth() > 0 ? source.getWidth() : Math.max(1, source.getPreferredW()); + int height = source == null ? Math.max(36, contentH / 12) + : source.getHeight() > 0 ? source.getHeight() : Math.max(1, source.getPreferredH()); + int desiredX = Math.max(contentX(((Container) parentComponent)), Math.min(x, contentX(((Container) parentComponent)) + contentW - width)); + int desiredY = Math.max(contentY(((Container) parentComponent)), Math.min(y, contentY(((Container) parentComponent)) + contentH - height)); + SnapResult horizontal = snapAxis(((Container) parentComponent), source, desiredX, width, true); + SnapResult vertical = snapAxis(((Container) parentComponent), source, desiredY, height, false); + plan.snapX = horizontal.position; + plan.snapY = vertical.position; + plan.snapW = width; + plan.snapH = height; + plan.horizontalSnap = horizontal; + plan.verticalSnap = vertical; + if (horizontal.description != null && vertical.description != null) { + plan.snapDescription = horizontal.description + " • " + vertical.description; + } else { + plan.snapDescription = horizontal.description == null ? vertical.description : horizontal.description; + } + } + + private SnapResult snapAxis(Container parent, Component source, int desired, int size, boolean horizontal) { + List candidates = new ArrayList<>(); + int start = horizontal ? contentX(parent) : contentY(parent); + int length = horizontal ? contentWidth(parent) : contentHeight(parent); + candidates.add(new SnapCandidate(start, horizontal ? "dock left" : "dock top", null, "parentStart")); + candidates.add(new SnapCandidate(start + length - size, horizontal ? "dock right" : "dock bottom", null, "parentEnd")); + candidates.add(new SnapCandidate(start + (length - size) / 2, + horizontal ? "center horizontally" : "center vertically", null, "parentCenter")); + int gap = Math.max(6, Display.getInstance().convertToPixels(1)); + for (int i = 0; i < parent.getComponentCount(); i++) { + Component sibling = parent.getComponentAt(i); + if (sibling == source || sibling == boxDropSpacer || sibling.getClientProperty("gui.element") == null) continue; + Element siblingElement = (Element) sibling.getClientProperty("gui.element"); + int siblingStart = horizontal ? sibling.getAbsoluteX() : sibling.getAbsoluteY(); + int siblingSize = horizontal ? sibling.getWidth() : sibling.getHeight(); + String name = sibling.getName() == null ? "component" : sibling.getName().replace("preview.", ""); + candidates.add(new SnapCandidate(siblingStart, "align start with " + name, siblingElement, "alignStart")); + candidates.add(new SnapCandidate(siblingStart + siblingSize - size, "align end with " + name, siblingElement, "alignEnd")); + candidates.add(new SnapCandidate(siblingStart + (siblingSize - size) / 2, + "align center with " + name, siblingElement, "alignCenter")); + candidates.add(new SnapCandidate(siblingStart + siblingSize + gap, "space after " + name, siblingElement, "after")); + candidates.add(new SnapCandidate(siblingStart - size - gap, "space before " + name, siblingElement, "before")); + if (!horizontal) { + int sourceBaseline = source == null ? -1 : source.getBaseline( + source.getWidth() > 0 ? source.getWidth() : source.getPreferredW(), + source.getHeight() > 0 ? source.getHeight() : source.getPreferredH()); + int siblingBaseline = sibling.getBaseline(sibling.getWidth(), sibling.getHeight()); + if (sourceBaseline >= 0 && siblingBaseline >= 0) { + candidates.add(new SnapCandidate(sibling.getAbsoluteY() + siblingBaseline - sourceBaseline, + "align baseline with " + name, siblingElement, "baseline")); + } + } + } + int threshold = Math.max(8, Display.getInstance().convertToPixels(1.5f)); + int best = desired; + int bestDistance = threshold + 1; + SnapCandidate selected = null; + for (int i = 0; i < candidates.size(); i++) { + SnapCandidate candidate = candidates.get(i); + int distance = Math.abs(candidate.position - desired); + if (distance < bestDistance) { + best = candidate.position; + bestDistance = distance; + selected = candidate; + } + } + best = Math.max(start, Math.min(best, start + length - size)); + if (bestDistance > threshold) selected = null; + return new SnapResult(best, selected == null ? null : selected.description, + selected == null ? null : selected.reference, selected == null ? null : selected.kind); + } + + private String firstAvailableBorderConstraint(Element parent, Element excluding) { + String[] constraints = {"Center", "North", "South", "West", "East"}; + for (String candidate : constraints) { + if (GuiDocument.childAtBorderConstraint(parent, candidate, excluding) == null) return candidate; + } + return excluding != null && document.parentOf(excluding) == parent + ? GuiDocument.effectiveBorderConstraint(parent, excluding) : "Center"; + } + + boolean applyDropPlan(Element dragged, DropPlan plan, int x, int y) { + if (document == null || plan == null || plan.document != document + || !document.containsElement(dragged) + || !document.containsElement(plan.parent) + || !document.containsElement(plan.target) + || (plan.occupied != null && !document.containsElement(plan.occupied))) return false; + document.beginTransaction(); + try { + return applyDropPlanImpl(dragged, plan, x, y); + } finally { + document.endTransaction(); + } + } + + private boolean applyDropPlanImpl(Element dragged, DropPlan plan, int x, int y) { + Element oldParent = document.parentOf(dragged); + if ("TableLayout".equals(plan.layout) && plan.tableCell != null) { + return applyTableDrop(dragged, plan, oldParent); + } + int oldIndex = document.componentIndex(oldParent, dragged); + String oldLayout = oldParent == null ? "" : value(oldParent, "layout", "BoxLayout"); + String oldLayeredInsets = dragged.getAttribute("layeredInsets"); + String oldConstraint = oldParent == null || !"BorderLayout".equals(value(oldParent, "layout", "BoxLayout")) + ? null : GuiDocument.effectiveBorderConstraint(oldParent, dragged); + if (plan.occupied != null && oldParent == plan.parent) { + document.select(plan.occupied); + document.setAttribute("layoutConstraint", oldConstraint); + document.select(dragged); + document.setAttribute("layoutConstraint", plan.constraint); + return true; + } + if (plan.occupied != null) { + int destinationIndex = document.componentIndex(plan.parent, plan.occupied); + document.select(plan.occupied); + if (!document.moveSelectedToParent(oldParent, oldIndex)) return false; + if ("BorderLayout".equals(oldLayout)) { + document.setAttribute("layoutConstraint", oldConstraint); + document.setAttribute("layeredInsets", null); + } else if ("LayeredLayout".equals(oldLayout)) { + document.setAttribute("layoutConstraint", null); + document.setAttribute("layeredInsets", oldLayeredInsets); + } else { + document.setAttribute("layoutConstraint", null); + document.setAttribute("layeredInsets", null); + } + document.select(dragged); + if (!document.moveSelectedToParent(plan.parent, destinationIndex)) return false; + } + document.select(dragged); + // A positional change inside a LayeredLayout must not also change stacking/XML order. + // Apart from being surprising, that made commit differ from the glass simulation and + // could cause a referenced sibling chain to be laid out in a different pass. Moving to a + // different parent still needs a structural move; reordering within the same guided + // parent belongs to the explicit tree/z-order commands. + boolean sameGuidedParent = "LayeredLayout".equals(plan.layout) && oldParent == plan.parent; + boolean moved = plan.occupied != null || sameGuidedParent || document.moveSelectedTo(plan.target, plan.after); + if (!moved && document.parentOf(dragged) != plan.parent) return false; + document.select(dragged); + if ("BorderLayout".equals(plan.layout)) { + document.setAttribute("layoutConstraint", plan.constraint); + document.setAttribute("layeredInsets", null); + } else if ("LayeredLayout".equals(plan.layout)) { + document.setAttribute("layoutConstraint", null); + Component parentPreview = componentForElement(canvasHost, plan.parent); + if (parentPreview != null && parentPreview.getWidth() > 0 && parentPreview.getHeight() > 0) { + Component source = componentForElement(canvasHost, dragged); + int width = plan.snapW > 0 ? plan.snapW + : source == null || source.getWidth() < 1 ? Math.max(1, parentPreview.getWidth() / 5) : source.getWidth(); + int height = plan.snapH > 0 ? plan.snapH + : source == null || source.getHeight() < 1 ? Math.max(1, parentPreview.getHeight() / 10) : source.getHeight(); + if (plan.horizontalSnap != null || plan.verticalSnap != null) { + prepareGuidedCycleBreak(document, dragged, plan); + persistGuidedConstraints(document, dragged, plan, (Container) parentPreview, source, width, height); + return true; + } + int contentW = contentWidth(parentPreview); + int contentH = contentHeight(parentPreview); + int marginLeft = source == null ? 0 : source.getStyle().getMarginLeftNoRTL(); + int marginRight = source == null ? 0 : source.getStyle().getMarginRightNoRTL(); + int marginTop = source == null ? 0 : source.getStyle().getMarginTop(); + int marginBottom = source == null ? 0 : source.getStyle().getMarginBottom(); + int leftPx = Math.max(0, plan.snapX - contentX(parentPreview) - marginLeft); + int topPx = Math.max(0, plan.snapY - contentY(parentPreview) - marginTop); + int rightPx = Math.max(0, contentW - leftPx - width - marginLeft - marginRight); + int bottomPx = Math.max(0, contentH - topPx - height - marginTop - marginBottom); + document.setAttribute("layeredInsets", topPx + "px " + rightPx + "px " + + bottomPx + "px " + leftPx + "px"); + } + } else if ("TableLayout".equals(plan.layout)) { + document.setAttribute("layoutConstraint", null); + document.setAttribute("layeredInsets", null); + normalizeTableCells(plan.parent); + document.select(dragged); + } else { + document.setAttribute("layoutConstraint", null); + document.setAttribute("layeredInsets", null); + } + return true; + } + + /** + * Commits a table drop as an explicit cell assignment. Only the dragged component and, when the + * aimed cell was taken, its previous occupant change cells; every other child keeps the cell it + * had. XML order is left alone as well, because in a table it carries no layout meaning and + * reordering it would churn the generated source for no visible reason. + */ + private boolean applyTableDrop(Element dragged, DropPlan plan, Element oldParent) { + int row = plan.tableCell[0]; + int column = plan.tableCell[1]; + Integer vacatedRow = parseInteger(dragged.getAttribute("tableRow")); + Integer vacatedColumn = parseInteger(dragged.getAttribute("tableColumn")); + if (oldParent != plan.parent) { + document.select(dragged); + if (!document.moveSelectedToParent(plan.parent, componentChildren(plan.parent).size())) return false; + } + if (plan.occupied != null) { + document.select(plan.occupied); + boolean canSwap = oldParent == plan.parent && vacatedRow != null && vacatedColumn != null; + int[] destination = canSwap ? new int[]{vacatedRow.intValue(), vacatedColumn.intValue()} + : firstFreeTableCell(plan.parent, dragged, plan.occupied); + document.setAttribute("tableRow", String.valueOf(destination[0])); + document.setAttribute("tableColumn", String.valueOf(destination[1])); + } + document.select(dragged); + document.setAttribute("layoutConstraint", null); + document.setAttribute("layeredInsets", null); + document.setAttribute("tableRow", String.valueOf(row)); + document.setAttribute("tableColumn", String.valueOf(column)); + normalizeTableCells(plan.parent); + document.select(dragged); + return true; + } + + /** The first cell in row-major order that no sibling claims, growing past the last row if needed. */ + private int[] firstFreeTableCell(Element parent, Element... ignored) { + int columns = Math.max(1, integer(value(parent, "tableLayoutColumns", "2"), 2)); + Set taken = new LinkedHashSet<>(); + for (Element child : componentChildren(parent)) { + boolean skip = false; + for (Element candidate : ignored) skip = skip || child == candidate; + if (skip) continue; + Integer row = parseInteger(child.getAttribute("tableRow")); + Integer column = parseInteger(child.getAttribute("tableColumn")); + if (row != null && column != null) taken.add(row + ":" + column); + } + for (int cursor = 0; ; cursor++) { + int row = cursor / columns; + int column = cursor % columns; + if (!taken.contains(row + ":" + column)) return new int[]{row, column}; + } + } + + private void persistGuidedConstraints(GuiDocument targetDocument, Element element, DropPlan plan, Container parent, + Component source, int width, int height) { + String[] insets = GuidedLayoutSupport.insetValues(element); + String[] references = GuidedLayoutSupport.referenceNames(element); + String[] positions = GuidedLayoutSupport.referencePositions(element); + String horizontalPolicy = GuidedLayoutSupport.horizontalPolicy(element); + String verticalPolicy = GuidedLayoutSupport.verticalPolicy(element); + String matchWidthName = value(element, "guidedMatchWidth", ""); + String matchHeightName = value(element, "guidedMatchHeight", ""); + Set retainedReferences = new LinkedHashSet<>(); + if (plan.horizontalSnap != null && plan.horizontalSnap.reference != null) { + retainedReferences.add(value(plan.horizontalSnap.reference, "name", "")); + } + if (plan.verticalSnap != null && plan.verticalSnap.reference != null) { + retainedReferences.add(value(plan.verticalSnap.reference, "name", "")); + } + int tearDistance = Math.max(6, Display.getInstance().convertToPixels(1)); + boolean pulledAway = source == null || Math.abs(plan.snapX - source.getAbsoluteX()) + + Math.abs(plan.snapY - source.getAbsoluteY()) > tearDistance; + int marginLeft = source == null ? 0 : source.getStyle().getMarginLeftNoRTL(); + int marginRight = source == null ? 0 : source.getStyle().getMarginRightNoRTL(); + int marginTop = source == null ? 0 : source.getStyle().getMarginTop(); + int marginBottom = source == null ? 0 : source.getStyle().getMarginBottom(); + int left = plan.snapX - contentX(parent) - marginLeft; + int top = plan.snapY - contentY(parent) - marginTop; + int right = contentWidth(parent) - left - width - marginLeft - marginRight; + int bottom = contentHeight(parent) - top - height - marginTop - marginBottom; + + clearHorizontalReferences(references, positions); + clearVerticalReferences(references, positions); + targetDocument.select(element); + targetDocument.setAttribute("guidedHorizontalAnchor", null); + targetDocument.setAttribute("guidedVerticalAnchor", null); + + applyHorizontalPosition(targetDocument, plan.horizontalSnap, insets, references, positions, left, right, width, source); + applyVerticalPosition(targetDocument, plan.verticalSnap, insets, references, positions, top, bottom, height, source); + + if (GuidedLayoutSupport.FILL.equals(horizontalPolicy) && pulledAway && retainedReferences.isEmpty()) { + horizontalPolicy = GuidedLayoutSupport.FIXED; + targetDocument.setAttribute("guidedMatchWidth", null); + } else if (GuidedLayoutSupport.FILL.equals(horizontalPolicy)) { + insets[3] = Math.max(0, left) + "px"; + insets[1] = Math.max(0, right) + "px"; + references[3] = references[1] = "-"; + } else if (GuidedLayoutSupport.MATCH.equals(horizontalPolicy)) { + Element match = namedSibling(plan.parent, matchWidthName); + if (pulledAway && !retainedReferences.contains(matchWidthName)) { + horizontalPolicy = GuidedLayoutSupport.FIXED; + targetDocument.setAttribute("guidedMatchWidth", null); + } else if (match != null) { + applyMatchedWidth(match, insets, references, positions, plan.snapX, width); + } else { + horizontalPolicy = GuidedLayoutSupport.FIXED; + targetDocument.setAttribute("guidedMatchWidth", null); + } + } + if (GuidedLayoutSupport.FIXED.equals(horizontalPolicy)) { + stabilizeFixedHorizontalSpan(plan.horizontalSnap, insets, references, positions, width, source); + targetDocument.setAttribute("guidedPreferredWidth", String.valueOf(Math.max(1, width))); + } else if (GuidedLayoutSupport.PREFERRED.equals(horizontalPolicy)) { + targetDocument.setAttribute("guidedPreferredWidth", null); + } + + if ("baseline".equals(plan.verticalSnap == null ? null : plan.verticalSnap.kind)) { + verticalPolicy = GuidedLayoutSupport.PREFERRED; + targetDocument.setAttribute("guidedPreferredHeight", null); + } else if (GuidedLayoutSupport.FILL.equals(verticalPolicy) && pulledAway && retainedReferences.isEmpty()) { + verticalPolicy = GuidedLayoutSupport.FIXED; + targetDocument.setAttribute("guidedMatchHeight", null); + } else if (GuidedLayoutSupport.FILL.equals(verticalPolicy)) { + insets[0] = Math.max(0, top) + "px"; + insets[2] = Math.max(0, bottom) + "px"; + references[0] = references[2] = "-"; + } else if (GuidedLayoutSupport.MATCH.equals(verticalPolicy)) { + Element match = namedSibling(plan.parent, matchHeightName); + if (pulledAway && !retainedReferences.contains(matchHeightName)) { + verticalPolicy = GuidedLayoutSupport.FIXED; + targetDocument.setAttribute("guidedMatchHeight", null); + } else if (match != null) { + applyMatchedHeight(match, insets, references, positions, plan.snapY, height); + } else { + verticalPolicy = GuidedLayoutSupport.FIXED; + targetDocument.setAttribute("guidedMatchHeight", null); + } + } + if (GuidedLayoutSupport.FIXED.equals(verticalPolicy)) { + stabilizeFixedVerticalSpan(plan.verticalSnap, insets, references, positions, height, source); + targetDocument.setAttribute("guidedPreferredHeight", String.valueOf(Math.max(1, height))); + } else if (GuidedLayoutSupport.PREFERRED.equals(verticalPolicy)) { + targetDocument.setAttribute("guidedPreferredHeight", null); + } + + targetDocument.setAttribute("guidedHorizontalSize", horizontalPolicy); + targetDocument.setAttribute("guidedVerticalSize", verticalPolicy); + targetDocument.setAttribute("layeredInsets", GuidedLayoutSupport.joinInsets(insets[0], insets[1], insets[2], insets[3])); + targetDocument.setAttribute("guidedReferences", GuidedLayoutSupport.joinReferences( + references[0], references[1], references[2], references[3])); + targetDocument.setAttribute("guidedReferencePositions", GuidedLayoutSupport.joinPositions( + positions[0], positions[1], positions[2], positions[3])); + } + + /** + * An auto opposite inset normally supplies preferred size. Near a parent edge LayeredLayout + * clamps that auto inset to zero and silently shrinks the component. Pair both sides with the + * same reference anchor instead: the relationship remains responsive, while the distance + * between the two constraints is the component's complete outer width. + */ + private void stabilizeFixedHorizontalSpan(SnapResult snap, String[] insets, String[] refs, + String[] positions, int width, Component source) { + if (snap == null || snap.reference == null) return; + String ref = value(snap.reference, "name", "-"); + int outer = Math.max(1, width + (source == null ? 0 + : source.getStyle().getMarginLeftNoRTL() + source.getStyle().getMarginRightNoRTL())); + int gap = standardGap(); + if ("alignStart".equals(snap.kind)) { + refs[1] = ref; positions[1] = "1"; insets[1] = (-outer) + "px"; + } else if ("alignEnd".equals(snap.kind)) { + refs[1] = ref; positions[1] = "0"; insets[1] = "0px"; + } else if ("after".equals(snap.kind)) { + refs[1] = ref; positions[1] = "0"; insets[1] = (-(outer + gap)) + "px"; + } else if ("before".equals(snap.kind)) { + refs[3] = ref; positions[3] = "0"; insets[3] = (-(outer + gap)) + "px"; + } + } + + private void stabilizeFixedVerticalSpan(SnapResult snap, String[] insets, String[] refs, + String[] positions, int height, Component source) { + if (snap == null || snap.reference == null || "baseline".equals(snap.kind)) return; + String ref = value(snap.reference, "name", "-"); + int outer = Math.max(1, height + (source == null ? 0 + : source.getStyle().getMarginTop() + source.getStyle().getMarginBottom())); + int gap = standardGap(); + if ("alignStart".equals(snap.kind)) { + refs[2] = ref; positions[2] = "1"; insets[2] = (-outer) + "px"; + } else if ("alignEnd".equals(snap.kind)) { + refs[2] = ref; positions[2] = "0"; insets[2] = "0px"; + } else if ("after".equals(snap.kind)) { + refs[2] = ref; positions[2] = "0"; insets[2] = (-(outer + gap)) + "px"; + } else if ("before".equals(snap.kind)) { + refs[0] = ref; positions[0] = "0"; insets[0] = (-(outer + gap)) + "px"; + } + } + + private void applyHorizontalPosition(GuiDocument targetDocument, SnapResult snap, String[] insets, String[] refs, + String[] positions, int left, int right, int width, Component source) { + String kind = snap == null ? null : snap.kind; + String ref = snap == null || snap.reference == null ? "-" : value(snap.reference, "name", "-"); + insets[1] = "auto"; + if ("parentEnd".equals(kind)) { + insets[3] = "auto"; insets[1] = "0px"; + } else if ("parentCenter".equals(kind)) { + insets[3] = "50%"; targetDocument.setAttribute("guidedHorizontalAnchor", "0.5"); + } else if ("alignStart".equals(kind)) { + insets[3] = "0px"; refs[3] = ref; positions[3] = "0"; + } else if ("alignEnd".equals(kind)) { + // A right inset referenced to a component can clip preferred width to the reference + // box. Express equal right edges as a left edge measured backwards from the + // reference's right edge so a position-only drag cannot resize the component. + int outerWidth = Math.max(1, width + (source == null ? 0 + : source.getStyle().getMarginLeftNoRTL() + source.getStyle().getMarginRightNoRTL())); + insets[3] = (-outerWidth) + "px"; insets[1] = "auto"; + refs[3] = ref; positions[3] = "1"; + } else if ("alignCenter".equals(kind)) { + insets[3] = "0%"; refs[3] = ref; positions[3] = "0.5"; + targetDocument.setAttribute("guidedHorizontalAnchor", "0.5"); + } else if ("after".equals(kind)) { + insets[3] = standardGap() + "px"; refs[3] = ref; positions[3] = "1"; + } else if ("before".equals(kind)) { + insets[3] = "auto"; insets[1] = standardGap() + "px"; refs[1] = ref; positions[1] = "1"; + } else { + insets[3] = Math.max(0, left) + "px"; + } + } + + private void applyVerticalPosition(GuiDocument targetDocument, SnapResult snap, String[] insets, String[] refs, + String[] positions, int top, int bottom, int height, Component source) { + String kind = snap == null ? null : snap.kind; + String ref = snap == null || snap.reference == null ? "-" : value(snap.reference, "name", "-"); + insets[2] = "auto"; + if ("parentEnd".equals(kind)) { + insets[0] = "auto"; insets[2] = "0px"; + } else if ("parentCenter".equals(kind)) { + insets[0] = "50%"; targetDocument.setAttribute("guidedVerticalAnchor", "0.5"); + } else if ("alignStart".equals(kind)) { + insets[0] = "0px"; refs[0] = ref; positions[0] = "0"; + } else if ("alignEnd".equals(kind)) { + int outerHeight = Math.max(1, height + (source == null ? 0 + : source.getStyle().getMarginTop() + source.getStyle().getMarginBottom())); + insets[0] = (-outerHeight) + "px"; insets[2] = "auto"; + refs[0] = ref; positions[0] = "1"; + } else if ("alignCenter".equals(kind)) { + insets[0] = "0%"; refs[0] = ref; positions[0] = "0.5"; + targetDocument.setAttribute("guidedVerticalAnchor", "0.5"); + } else if ("after".equals(kind)) { + insets[0] = standardGap() + "px"; refs[0] = ref; positions[0] = "1"; + } else if ("before".equals(kind)) { + insets[0] = "auto"; insets[2] = standardGap() + "px"; refs[2] = ref; positions[2] = "1"; + } else if ("baseline".equals(kind)) { + insets[0] = "baseline"; insets[2] = "auto"; refs[0] = ref; positions[0] = "0"; + } else { + insets[0] = Math.max(0, top) + "px"; + } + } + + private void applyMatchedWidth(Element match, String[] insets, String[] refs, String[] positions, + int absoluteX, int width) { + applyMatchedWidth(match, componentForElement(canvasHost, match), insets, refs, positions, absoluteX, width); + } + + private void applyMatchedWidth(Element match, Component target, String[] insets, String[] refs, + String[] positions, int absoluteX, int width) { + if (target == null) return; + int delta = absoluteX - target.getAbsoluteX(); + String name = value(match, "name", "-"); + insets[3] = delta + "px"; + insets[1] = (-delta) + "px"; + refs[3] = refs[1] = name; + positions[3] = positions[1] = "0"; + } + + private void applyMatchedHeight(Element match, String[] insets, String[] refs, String[] positions, + int absoluteY, int height) { + applyMatchedHeight(match, componentForElement(canvasHost, match), insets, refs, positions, absoluteY, height); + } + + private void applyMatchedHeight(Element match, Component target, String[] insets, String[] refs, + String[] positions, int absoluteY, int height) { + if (target == null) return; + int delta = absoluteY - target.getAbsoluteY(); + String name = value(match, "name", "-"); + insets[0] = delta + "px"; + insets[2] = (-delta) + "px"; + refs[0] = refs[2] = name; + positions[0] = positions[2] = "0"; + } + + private Element namedSibling(Element parent, String name) { + if (parent == null || name == null || name.length() == 0) return null; + for (Element child : componentChildren(parent)) if (name.equals(child.getAttribute("name"))) return child; + return null; + } + + /** A component cannot be anchored to one of its own transitive dependents. Before installing + * such a relationship, rebase the first dependent in each path onto the parent at its current + * pixels. This preserves the downstream UI exactly and makes the requested target a stable + * anchor instead of relying on LayeredLayout's circular-reference fallback. */ + private void prepareGuidedCycleBreak(GuiDocument targetDocument, Element targetDragged, DropPlan plan) { + String draggedName = value(targetDragged, "name", ""); + if (draggedName.length() == 0) return; + Set requestedReferences = new LinkedHashSet<>(); + if (plan.horizontalSnap != null && plan.horizontalSnap.reference != null) { + requestedReferences.add(value(plan.horizontalSnap.reference, "name", "")); + } + if (plan.verticalSnap != null && plan.verticalSnap.reference != null) { + requestedReferences.add(value(plan.verticalSnap.reference, "name", "")); + } + Set rebase = new LinkedHashSet<>(); + for (String requested : requestedReferences) { + if (requested.length() == 0 || !dependencyReachable(document, draggedName, requested)) continue; + for (Element candidate : document.components()) { + String candidateName = value(candidate, "name", ""); + if (!incomingReferenceNames(document, candidateName).contains(draggedName)) continue; + if (requested.equals(candidateName) || dependencyReachable(document, candidateName, requested)) { + rebase.add(candidateName); + } + } + } + for (String dependentName : rebase) { + freezeDependencyAtCurrentBounds(targetDocument, dependentName, draggedName); + } + } + + private boolean dependencyReachable(GuiDocument sourceDocument, String from, String to) { + if (from.equals(to)) return true; + List pending = new ArrayList<>(); + Set visited = new LinkedHashSet<>(); + pending.add(from); + while (!pending.isEmpty()) { + String current = pending.remove(0); + if (!visited.add(current)) continue; + for (Element candidate : sourceDocument.components()) { + String name = value(candidate, "name", ""); + if (name.length() == 0 || !incomingReferenceNames(sourceDocument, name).contains(current)) continue; + if (to.equals(name)) return true; + pending.add(name); + } + } + return false; + } + + private void freezeDependencyAtCurrentBounds(GuiDocument targetDocument, String dependentName, + String removedReference) { + Element original = findElementNamed(document, dependentName); + Element target = findElementNamed(targetDocument, dependentName); + Component preview = original == null ? null : componentForElement(canvasHost, original); + if (target == null || preview == null || preview.getParent() == null) return; + Container parent = preview.getParent(); + String[] insets = GuidedLayoutSupport.insetValues(target); + String[] refs = GuidedLayoutSupport.referenceNames(target); + String[] positions = GuidedLayoutSupport.referencePositions(target); + boolean horizontal = removedReference.equals(refs[1]) || removedReference.equals(refs[3]) + || removedReference.equals(value(target, "guidedMatchWidth", "")); + boolean vertical = removedReference.equals(refs[0]) || removedReference.equals(refs[2]) + || removedReference.equals(value(target, "guidedMatchHeight", "")); + targetDocument.select(target); + if (horizontal) { + insets[3] = Math.max(0, preview.getAbsoluteX() - contentX(parent) + - preview.getStyle().getMarginLeftNoRTL()) + "px"; + insets[1] = "auto"; + clearHorizontalReferences(refs, positions); + targetDocument.setAttribute("guidedHorizontalAnchor", null); + targetDocument.setAttribute("guidedMatchWidth", null); + // Detaching an axis must freeze the complete rendered rectangle. A component + // can currently render larger than its nominal preferred size because of theme + // metrics, margins, or its old relationship. Falling back to preferred size here + // resized it and pulled every downstream dependent along with it. + targetDocument.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.FIXED); + targetDocument.setAttribute("guidedPreferredWidth", String.valueOf(Math.max(1, preview.getWidth()))); + } + if (vertical) { + insets[0] = Math.max(0, preview.getAbsoluteY() - contentY(parent) + - preview.getStyle().getMarginTop()) + "px"; + insets[2] = "auto"; + clearVerticalReferences(refs, positions); + targetDocument.setAttribute("guidedVerticalAnchor", null); + targetDocument.setAttribute("guidedMatchHeight", null); + targetDocument.setAttribute("guidedVerticalSize", GuidedLayoutSupport.FIXED); + targetDocument.setAttribute("guidedPreferredHeight", String.valueOf(Math.max(1, preview.getHeight()))); + } + targetDocument.setAttribute("layeredInsets", GuidedLayoutSupport.joinInsets( + insets[0], insets[1], insets[2], insets[3])); + targetDocument.setAttribute("guidedReferences", GuidedLayoutSupport.joinReferences( + refs[0], refs[1], refs[2], refs[3])); + targetDocument.setAttribute("guidedReferencePositions", GuidedLayoutSupport.joinPositions( + positions[0], positions[1], positions[2], positions[3])); + } + + private void showGuidedDropSimulation(Element dragged, DropPlan plan, Container parent, Component source) { + GuidedSimulation simulation = simulateGuidedDrop(dragged, plan, parent, source); + showSimulation(simulation); + } + + private void showGuidedResizeSimulation(Element element, Container parent, Component source, + ResizePlan plan, int edges) { + GuidedSimulation simulation = simulateGuidedResize(element, parent, source, plan, edges); + showSimulation(simulation); + } + + private void showSimulation(GuidedSimulation simulation) { + if (dragGuideOverlay == null) return; + if (simulation == null) dragGuideOverlay.clearSimulation(); + else dragGuideOverlay.showSimulation(simulation.items, simulation.links, simulation.summary); + } + + /** Runs a proposed guided drop against an isolated XML clone. The active document and undo + * history remain untouched until pointer release. */ + GuidedSimulation simulateGuidedDrop(Element dragged, DropPlan plan, Container parent, Component source) { + if (document == null || previewRoot == null || dragged == null || plan == null || !plan.valid + || !"LayeredLayout".equals(plan.layout)) return null; + GuiDocument simulated = GuiDocument.parse(document.path(), document.toXml()); + Element simulatedDragged = equivalentElement(simulated, dragged); + Element simulatedParent = equivalentElement(simulated, plan.parent); + if (simulatedDragged == null || simulatedParent == null) return null; + if (simulated.parentOf(simulatedDragged) != simulatedParent) { + Element simulatedTarget = equivalentElement(simulated, plan.target); + simulated.select(simulatedDragged); + if (simulatedTarget == null || !simulated.moveSelectedTo(simulatedTarget, plan.after)) return null; + } + int width = plan.snapW > 0 ? plan.snapW : source == null || source.getWidth() < 1 + ? Math.max(1, parent.getWidth() / 5) : source.getWidth(); + int height = plan.snapH > 0 ? plan.snapH : source == null || source.getHeight() < 1 + ? Math.max(1, parent.getHeight() / 10) : source.getHeight(); + prepareGuidedCycleBreak(simulated, simulatedDragged, plan); + persistGuidedConstraints(simulated, simulatedDragged, plan, parent, source, width, height); + return buildGuidedSimulation(simulated, value(dragged, "name", "component")); + } + + /** Runs a proposed resize against an isolated XML clone, including all existing match and + * positional references so its full cascade can be painted before commit. */ + GuidedSimulation simulateGuidedResize(Element element, Container parent, Component source, + ResizePlan plan, int edges) { + if (document == null || previewRoot == null || element == null || plan == null) return null; + GuiDocument simulated = GuiDocument.parse(document.path(), document.toXml()); + Element simulatedElement = equivalentElement(simulated, element); + if (simulatedElement == null) return null; + boolean group = selectedElements.size() > 1 && selectedElements.contains(element) + && selectedElementsShareGuidedParent(); + commitGuidedResize(simulated, simulatedElement, parent, source, + group ? fixedReferenceResizePlan(plan, edges) : plan, edges); + if (group) { + for (Element selected : selectedElements) { + if (selected == element) continue; + Element simulatedSelected = equivalentElement(simulated, selected); + Component preview = componentForElement(canvasHost, selected); + if (simulatedSelected == null || preview == null) continue; + if ((edges & 3) != 0) { + applyLayeredRelationship(simulated, simulatedSelected, simulatedElement, "matchWidth", preview); + } + if ((edges & 12) != 0) { + applyLayeredRelationship(simulated, simulatedSelected, simulatedElement, "matchHeight", preview); + } + } + simulated.select(simulatedElement); + } + return buildGuidedSimulation(simulated, value(element, "name", "component")); + } + + private GuidedSimulation buildGuidedSimulation(GuiDocument simulated, String activeName) { + Component originalRoot = previewRoot; + if (!(originalRoot instanceof Container) || originalRoot.getWidth() < 1 || originalRoot.getHeight() < 1) return null; + Map before = previewBounds(document, (Container) originalRoot); + Component rendered; + try { + if (projectTheme != null) UIManager.getInstance().setThemeProps(projectTheme); + rendered = ComponentPreviewFactory.create(simulated.root(), null, simulationSelectionHandler()); + if (projectTheme != null) rendered.refreshTheme(); + } finally { + if (builderTheme != null) UIManager.getInstance().setThemeProps(builderTheme); + } + ComponentPreviewFactory.stabilizeDesignStyles(rendered); + rendered.setX(originalRoot.getAbsoluteX()); + rendered.setY(originalRoot.getAbsoluteY()); + rendered.setWidth(originalRoot.getWidth()); + rendered.setHeight(originalRoot.getHeight()); + layoutSimulation((Container) rendered); + Map after = previewBounds(simulated, (Container) rendered); + Set changed = new LinkedHashSet<>(); + List items = new ArrayList<>(); + for (Map.Entry entry : after.entrySet()) { + String name = entry.getKey(); + PreviewRect oldRect = before.get(name); + PreviewRect newRect = entry.getValue(); + if (oldRect == null) continue; + boolean activeItem = name.equals(activeName); + if (!activeItem && !oldRect.differs(newRect)) continue; + if (oldRect.differs(newRect)) changed.add(name); + items.add(new DragGuideOverlay.GlassItem(name, + oldRect.x, oldRect.y, oldRect.width, oldRect.height, + newRect.x, newRect.y, newRect.width, newRect.height, activeItem)); + } + List links = dependencyLinks(simulated, after, changed, activeName); + Map originalPairs = dependencyPairs(document); + Map simulatedPairs = dependencyPairs(simulated); + List rebased = new ArrayList<>(); + Set detachedFromActive = new LinkedHashSet<>(); + for (Map.Entry entry : originalPairs.entrySet()) { + if (simulatedPairs.containsKey(entry.getKey())) continue; + String reference = entry.getValue()[0]; + String dependent = entry.getValue()[1]; + PreviewRect referenceBefore = before.get(reference); + PreviewRect dependentAfter = after.get(dependent); + if (referenceBefore != null && dependentAfter != null) { + links.add(new DragGuideOverlay.DependencyLink(reference, dependent, + referenceBefore.x + referenceBefore.width / 2, + referenceBefore.y + referenceBefore.height / 2, + dependentAfter.x + dependentAfter.width / 2, + dependentAfter.y + dependentAfter.height / 2, true)); + } + if (activeName.equals(dependent)) detachedFromActive.add(reference); + else rebased.add(dependent + " from " + reference); + } + List affected = new ArrayList<>(); + for (String name : changed) if (!name.equals(activeName)) affected.add(name); + String summary = "Preview: " + activeName; + if (changed.contains(activeName)) summary += " changes"; + if (!affected.isEmpty()) summary += " • also affects " + joinNames(affected); + else summary += " • no dependent components change"; + if (!detachedFromActive.isEmpty()) summary += " • detaches from " + joinNames(new ArrayList<>(detachedFromActive)); + if (!rebased.isEmpty()) summary += " • keeps in place " + joinNames(rebased); + return new GuidedSimulation(simulated, items, links, summary, changed); + } + + private Map dependencyPairs(GuiDocument targetDocument) { + Map result = new LinkedHashMap<>(); + for (Element dependent : targetDocument.components()) { + String dependentName = value(dependent, "name", ""); + if (dependentName.length() == 0) continue; + for (String reference : incomingReferenceNames(targetDocument, dependentName)) { + String key = reference + "\n" + dependentName; + result.put(key, new String[]{reference, dependentName}); + } + } + return result; + } + + private Set incomingReferenceNames(GuiDocument targetDocument, String componentName) { + Set result = new LinkedHashSet<>(); + Element element = findElementNamed(targetDocument, componentName); + if (element == null) return result; + for (String reference : GuidedLayoutSupport.referenceNames(element)) { + if (reference != null && reference.length() > 0 && !"-".equals(reference)) result.add(reference); + } + String matchWidth = value(element, "guidedMatchWidth", ""); + String matchHeight = value(element, "guidedMatchHeight", ""); + if (matchWidth.length() > 0) result.add(matchWidth); + if (matchHeight.length() > 0) result.add(matchHeight); + return result; + } + + private String joinNames(List names) { + StringBuilder out = new StringBuilder(); + for (String name : names) { + if (out.length() > 0) out.append(", "); + out.append(name); + } + return out.toString(); + } + + private List dependencyLinks(GuiDocument simulated, + Map bounds, Set changed, String activeName) { + List result = new ArrayList<>(); + Set unique = new LinkedHashSet<>(); + for (Element dependent : simulated.components()) { + String dependentName = value(dependent, "name", ""); + if (dependentName.length() == 0) continue; + Set references = new LinkedHashSet<>(); + for (String reference : GuidedLayoutSupport.referenceNames(dependent)) { + if (reference != null && reference.length() > 0 && !"-".equals(reference)) references.add(reference); + } + String matchWidth = value(dependent, "guidedMatchWidth", ""); + String matchHeight = value(dependent, "guidedMatchHeight", ""); + if (matchWidth.length() > 0) references.add(matchWidth); + if (matchHeight.length() > 0) references.add(matchHeight); + for (String reference : references) { + if (!changed.contains(dependentName) && !changed.contains(reference) + && !activeName.equals(dependentName) && !activeName.equals(reference)) continue; + PreviewRect from = bounds.get(reference); + PreviewRect to = bounds.get(dependentName); + String key = reference + "\n" + dependentName; + if (from == null || to == null || !unique.add(key)) continue; + result.add(new DragGuideOverlay.DependencyLink(reference, dependentName, + from.x + from.width / 2, from.y + from.height / 2, + to.x + to.width / 2, to.y + to.height / 2)); + } + } + return result; + } + + private Map previewBounds(GuiDocument targetDocument, Container root) { + Map result = new LinkedHashMap<>(); + for (Element element : targetDocument.components()) { + if (element == targetDocument.root()) continue; + String name = value(element, "name", ""); + Component component = componentForElement(root, element); + if (name.length() == 0 || component == null) continue; + result.put(name, new PreviewRect(component.getAbsoluteX(), component.getAbsoluteY(), + component.getWidth(), component.getHeight())); + } + return result; + } + + private void layoutSimulation(Container container) { + container.layoutContainer(); + for (int i = 0; i < container.getComponentCount(); i++) { + Component child = container.getComponentAt(i); + if (child instanceof Container) layoutSimulation(((Container) child)); + } + } + + private ComponentPreviewFactory.SelectionHandler simulationSelectionHandler() { + return new ComponentPreviewFactory.SelectionHandler() { + @Override public void selected(Element element) { } + @Override public void dragPressed(Element element, Component source, int x, int y) { } + @Override public boolean isDragActive() { return false; } + @Override public void editContent(Element element) { } + }; + } + + private Element equivalentElement(GuiDocument targetDocument, Element original) { + if (original == null) return null; + if (document != null && original == document.root()) return targetDocument.root(); + String name = value(original, "name", ""); + if (name.length() == 0) return null; + return findElementNamed(targetDocument, name); + } + + private Element findElementNamed(GuiDocument targetDocument, String name) { + if (targetDocument == null || name == null || name.length() == 0) return null; + for (Element candidate : targetDocument.components()) { + if (name.equals(candidate.getAttribute("name"))) return candidate; + } + return null; + } + + private void clearHorizontalReferences(String[] refs, String[] positions) { + refs[1] = refs[3] = "-"; positions[1] = positions[3] = "0"; + } + + private void clearVerticalReferences(String[] refs, String[] positions) { + refs[0] = refs[2] = "-"; positions[0] = positions[2] = "0"; + } + + private int standardGap() { return Math.max(6, Display.getInstance().convertToPixels(1)); } + + /** + * Gives every child of a table a cell, without ever moving a child that already has one. + * + *

Reassigning cells from sibling order is what an implicit, code-first TableLayout does, and + * it is wrong for a designer: dropping one component then renumbers every other cell, so the + * whole table jumps to a layout nobody asked for. Cells are the user's placement decision, so + * only components that have no cell yet, or whose cell collides with an earlier sibling, are + * assigned one, and they take the first free cell in row-major order. + */ + private void normalizeTableCells(Element parent) { + int columns = Math.max(1, integer(value(parent, "tableLayoutColumns", "2"), 2)); + List children = componentChildren(parent); + Set taken = new LinkedHashSet<>(); + List unplaced = new ArrayList<>(); + for (Element child : children) { + Integer row = parseInteger(child.getAttribute("tableRow")); + Integer column = parseInteger(child.getAttribute("tableColumn")); + if (row == null || column == null || row.intValue() < 0 || column.intValue() < 0 + || column.intValue() >= columns || !taken.add(row + ":" + column)) { + unplaced.add(child); + } + } + int cursor = 0; + for (Element child : unplaced) { + while (taken.contains((cursor / columns) + ":" + (cursor % columns))) cursor++; + taken.add((cursor / columns) + ":" + (cursor % columns)); + document.select(child); + document.setAttribute("tableRow", String.valueOf(cursor / columns)); + document.setAttribute("tableColumn", String.valueOf(cursor % columns)); + } + growTableToFit(parent, taken); + } + + /** Widens the declared row count so no assigned cell falls outside the table and disappears. */ + private void growTableToFit(Element parent, Set occupiedCells) { + int requiredRows = 1; + for (String cell : occupiedCells) { + Integer row = parseInteger(cell.substring(0, cell.indexOf(':'))); + if (row != null) requiredRows = Math.max(requiredRows, row.intValue() + 1); + } + if (integer(value(parent, "tableLayoutRows", "2"), 2) < requiredRows) { + document.select(parent); + document.setAttribute("tableLayoutRows", String.valueOf(requiredRows)); + } + } + + /** The child occupying {@code row}/{@code column}, or null. */ + /** + * The child occupying a cell, counting the rectangle a span covers rather than only the cell a + * child starts in. Testing the anchor alone treated a covered cell as empty, so a drop there + * was assigned a cell the span already owned and the rebuild overlapped or failed. + * + * @param parent the table + * @param row the row being dropped on + * @param column the column being dropped on + * @param ignored a child to skip, typically the one being dragged + * @return the child covering that cell, or null when it is genuinely free + */ + private Element childAtTableCell(Element parent, int row, int column, Element ignored) { + for (Element child : componentChildren(parent)) { + if (child == ignored) continue; + Integer childRow = parseInteger(child.getAttribute("tableRow")); + Integer childColumn = parseInteger(child.getAttribute("tableColumn")); + if (childRow == null || childColumn == null) continue; + int rowSpan = Math.max(1, integer(child.getAttribute("tableVerticalSpan"), 1)); + int columnSpan = Math.max(1, integer(child.getAttribute("tableHorizontalSpan"), 1)); + if (row >= childRow.intValue() && row < childRow.intValue() + rowSpan + && column >= childColumn.intValue() && column < childColumn.intValue() + columnSpan) { + return child; + } + } + return null; + } + + /** + * The cell under the pointer, derived from the parent preview's own geometry rather than from + * sibling order, so a drop lands where it was aimed. + */ + int[] tableCellAt(Element parent, Component parentPreview, int x, int y) { + int columns = Math.max(1, integer(value(parent, "tableLayoutColumns", "2"), 2)); + int rows = Math.max(1, integer(value(parent, "tableLayoutRows", "2"), 2)); + if (parentPreview == null || parentPreview.getWidth() < 1 || parentPreview.getHeight() < 1) { + return new int[]{0, 0}; + } + int width = Math.max(1, contentWidth(parentPreview)); + int height = Math.max(1, contentHeight(parentPreview)); + int column = ((x - contentX(parentPreview)) * columns) / width; + int row = ((y - contentY(parentPreview)) * rows) / height; + return new int[]{Math.max(0, Math.min(rows - 1, row)), Math.max(0, Math.min(columns - 1, column))}; + } + + private List componentChildren(Element parent) { + List children = new ArrayList<>(); + if (parent == null) return children; + for (int i = 0; i < parent.getNumChildren(); i++) { + Object child = parent.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) children.add(((Element) child)); + } + return children; + } + + static final class DropPlan { + GuiDocument document; + Element target; + Element parent; + Element occupied; + String layout; + String constraint; + String message; + boolean after; + boolean valid; + int snapX; + int snapY; + int snapW; + int snapH; + String snapDescription; + SnapResult horizontalSnap; + SnapResult verticalSnap; + /** {row, column} for a TableLayout drop; null for every other layout. */ + int[] tableCell; + } + + static final class GuidedSimulation { + final GuiDocument document; + final List items; + final List links; + final String summary; + final Set changedNames; + + GuidedSimulation(GuiDocument document, List items, + List links, String summary, Set changedNames) { + this.document = document; + this.items = items; + this.links = links; + this.summary = summary; + this.changedNames = changedNames; + } + } + + private static final class GroupMoveGeometry { + final Container parent; + final Map previews; + final int dx; + final int dy; + + GroupMoveGeometry(Container parent, Map previews, int dx, int dy) { + this.parent = parent; + this.previews = previews; + this.dx = dx; + this.dy = dy; + } + } + + private static final class PreviewRect { + final int x, y, width, height; + PreviewRect(int x, int y, int width, int height) { + this.x = x; this.y = y; this.width = width; this.height = height; + } + boolean differs(PreviewRect other) { + return other == null || Math.abs(x - other.x) > 1 || Math.abs(y - other.y) > 1 + || Math.abs(width - other.width) > 1 || Math.abs(height - other.height) > 1; + } + } + + private static final class ResizeHit { + final Element element; + final Component component; + final int edges; + + ResizeHit(Element element, Component component, int edges) { + this.element = element; + this.component = component; + this.edges = edges; + } + } + + static final class ResizePlan { + int x; + int y; + int width; + int height; + Element matchWidth; + Element matchHeight; + String horizontalDescription; + String verticalDescription; + String description; + ResizePlan(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + } + + static final class SnapResult { + final int position; + final String description; + final Element reference; + final String kind; + SnapResult(int position, String description, Element reference, String kind) { + this.position = position; + this.description = description; + this.reference = reference; + this.kind = kind; + } + } + + private static final class SnapCandidate { + final int position; + final String description; + final Element reference; + final String kind; + SnapCandidate(int position, String description, Element reference, String kind) { + this.position = position; + this.description = description; + this.reference = reference; + this.kind = kind; + } + } + + private static int contentX(Component component) { + return component.getAbsoluteX() + component.getStyle().getPaddingLeftNoRTL(); + } + + private static int contentY(Component component) { + return component.getAbsoluteY() + component.getStyle().getPaddingTop(); + } + + private static int contentWidth(Component component) { + return Math.max(1, component.getWidth() - component.getStyle().getPaddingLeftNoRTL() + - component.getStyle().getPaddingRightNoRTL()); + } + + private static int contentHeight(Component component) { + return Math.max(1, component.getHeight() - component.getStyle().getPaddingTop() + - component.getStyle().getPaddingBottom()); + } + + private Component componentForElement(Container root, Element element) { + if (element == null) return null; + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component.getClientProperty("gui.element") == element) return component; + if (component instanceof Container) { + Component nested = componentForElement(((Container) component), element); + if (nested != null) return nested; + } + } + return null; + } + + Element elementAt(Container root, int x, int y) { + for (int i = root.getComponentCount() - 1; i >= 0; i--) { + Component component = root.getComponentAt(i); + int ax = component.getAbsoluteX(); + int ay = component.getAbsoluteY(); + if (x < ax || x > ax + component.getWidth() || y < ay || y > ay + component.getHeight()) continue; + if (component instanceof Container) { + Element nested = elementAt(((Container) component), x, y); + if (nested != null) return nested; + } + Object element = component.getClientProperty("gui.element"); + if (element instanceof Element) return ((Element) element); + Object dropTarget = component.getClientProperty("gui.dropTargetElement"); + if (dropTarget instanceof Element) return ((Element) dropTarget); + } + return null; + } + + private Element activePreviewElementAt(int x, int y) { + if (document == null || canvasHost == null) return null; + Element hit = elementAt(canvasHost, x, y); + return document.containsElement(hit) ? hit : null; + } + + private Element designerDropTargetAt(Element dragged, int x, int y) { + return normalizeDesignerDropTarget(dragged, activePreviewElementAt(x, y)); + } + + Element normalizeDesignerDropTarget(Element dragged, Element target) { + if (dragged == null || target == null) return target; + if (target == dragged || containsElementIdentity(dragged, target)) return document.parentOf(dragged); + return target; + } + + private boolean containsElementIdentity(Element ancestor, Element candidate) { + if (ancestor == null || candidate == null) return false; + for (int i = 0; i < ancestor.getNumChildren(); i++) { + Object child = ancestor.getChildAt(i); + if (child == candidate) return true; + if (child instanceof Element && containsElementIdentity(((Element) child), candidate)) return true; + } + return false; + } + + boolean isActiveDocumentElement(Element element) { + return document != null && document.containsElement(element); + } + + List selectedElementsSnapshot() { + return new ArrayList<>(selectedElements); + } + + Container canvasHostForTest() { return canvasHost; } + + CodeEditor activeEditorForTest() { return activeCodeEditor; } + + /** Recompiles theme.css from disk and pushes it at the preview, as a live CSS edit does. */ + boolean reloadProjectCssForTest() { + try { + return applyProjectCss(ProjectIO.read(binding.cssFile()), new CodeEditor("css", "")); + } catch (IOException ex) { + return false; + } + } + + GuiDocument documentForTest() { return document; } + + private void cancelDesignerDrag() { + designerDraggedElement = null; + designerDragDocument = null; + designerPaletteType = null; + designerDragSource = null; + designerSuppressAction = null; + designerDragArmed = false; + designerDragActive = false; + guidedResizeElement = null; + guidedResizeSource = null; + guidedResizeArmed = false; + guidedResizeActive = false; + activeResizePlan = null; + hideDropGuide(); + } + + private void refreshGuidedSelectionOverlay() { + if (dragGuideOverlay == null || document == null || canvasHost == null) return; + normalizeSelection(); + List components = new ArrayList<>(); + for (Element element : selectedElements) { + Component component = componentForElement(canvasHost, element); + if (component != null && "LayeredLayout".equals(document.parentLayout(element))) components.add(component); + } + Component primary = document.selected() == null ? null : componentForElement(canvasHost, document.selected()); + if (!components.isEmpty()) { + dragGuideOverlay.showSelections(components, primary); + } else { + dragGuideOverlay.clearSelection(); + } + refreshSelectionActions(components); + } + + private void normalizeSelection() { + if (document == null) { + selectedElements.clear(); + return; + } + rebindSelectionToDocument(); + selectedElements.removeIf(element -> !document.containsElement(element)); + Element primary = document.selected(); + if (selectedElements.isEmpty() && primary != null && primary != document.root()) selectedElements.add(primary); + if (!selectedElements.isEmpty() && !selectedElements.contains(primary)) { + document.select(selectedElements.iterator().next()); + } + } + + /** + * Re-resolves the selection against the live tree by component name. + * + *

Undo and redo restore by reparsing, so every Element identity changes even though the + * form is unchanged. Everything the editor holds -- the multi-selection here, and the drag + * state cleared alongside it -- then refers to a tree the document has thrown away. Dropping + * those entries would silently deselect after every undo; names are unique in a document, so + * they survive the reparse and are the right key to re-resolve against. + */ + private void rebindSelectionToDocument() { + if (selectedElements.isEmpty()) return; + List rebound = new ArrayList<>(); + for (Element element : selectedElements) { + if (document.containsElement(element)) { + rebound.add(element); + continue; + } + String name = element.getAttribute("name"); + Element live = name == null ? null : findElementNamed(document, name); + if (live != null) rebound.add(live); + } + if (rebound.equals(new ArrayList<>(selectedElements))) return; + selectedElements.clear(); + selectedElements.addAll(rebound); + } + + private void selectElement(Element element, boolean additive, String source) { + if (document == null || element == null || !document.containsElement(element)) return; + if (element == document.root()) { + selectedElements.clear(); + document.select(element); + } else if (additive) { + if (selectedElements.contains(element)) { + boolean removedReference = element == document.selected(); + selectedElements.remove(element); + if (selectedElements.isEmpty()) document.select(document.root()); + else if (removedReference) document.select(selectedElements.iterator().next()); + } else { + Element reference = selectedElements.contains(document.selected()) ? document.selected() : null; + selectedElements.add(element); + // Modifier-click grows the group without silently changing the component that + // alignment and size actions use as their reference. + document.select(reference == null ? element : reference); + } + } else if (selectedElements.size() > 1 && selectedElements.contains(element)) { + // Keep the group intact when a selected member becomes the drag handle. Modifier + // clicks still toggle membership; clicking an unselected component starts a new group. + document.select(element); + } else { + selectedElements.clear(); + selectedElements.add(element); + document.select(element); + } + recordAction("selection_changed", "component", value(element, "name", value(element, "type", "component")), + "source", source, "additive", Boolean.valueOf(additive), "count", Integer.valueOf(selectedElements.size())); + if (inspectorHost != null) refreshInspector(); + if (hierarchyPanel != null) refreshHierarchy(); + refreshGuidedSelectionOverlay(); + setStatus(selectedElements.size() > 1 ? selectedElements.size() + " components selected • reference: " + + value(document.selected(), "name", "component") + + " • click a selected component to change the reference" + : selectedElements.isEmpty() ? "Selection cleared" + : "Selected " + value(document.selected(), "name", value(document.selected(), "type", "component")) + " • drag to reposition"); + } + + private void refreshSelectionActions(List selectedComponents) { + if (selectionActions == null || canvasOverlayHost == null) return; + boolean show = selectedComponents != null && selectedComponents.size() > 1 + && selectedElementsShareGuidedParent(); + selectionActions.setVisible(show); + if (!show) return; + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + for (Component component : selectedComponents) { + minX = Math.min(minX, component.getAbsoluteX()); + minY = Math.min(minY, component.getAbsoluteY()); + maxX = Math.max(maxX, component.getAbsoluteX() + component.getWidth()); + } + int paletteWidth = Math.max(1, selectionActions.getPreferredW()); + int paletteHeight = Math.max(1, selectionActions.getPreferredH()); + int hostX = canvasOverlayHost.getAbsoluteX(); + int hostY = canvasOverlayHost.getAbsoluteY(); + int beside = maxX - hostX + 8; + boolean fitsBeside = beside + paletteWidth <= canvasOverlayHost.getWidth() - 4; + int left = fitsBeside ? beside : Math.max(4, Math.min(canvasOverlayHost.getWidth() - paletteWidth - 4, + (minX + maxX - paletteWidth) / 2 - hostX)); + int top = fitsBeside ? Math.max(4, minY - hostY) + : Math.max(4, minY - hostY - paletteHeight - 8); + ((LayeredLayout) canvasOverlayHost.getLayout()).setInsets(selectionActions, + top + "px auto auto " + left + "px"); + selectionActions.revalidate(); + } + + private boolean selectedElementsShareGuidedParent() { + Element parent = null; + for (Element element : selectedElements) { + Element candidate = document.parentOf(element); + if (candidate == null || !"LayeredLayout".equals(value(candidate, "layout", "BoxLayout"))) return false; + if (parent == null) parent = candidate; + else if (parent != candidate) return false; + } + return parent != null; + } + + void autoScrollDuringDrag(int x, int y) { + if (canvasHost == null) return; + Component hit = deepestComponentAt(canvasHost, x, y); + Container scrollable = hit instanceof Container ? (Container) hit : hit == null ? null : hit.getParent(); + while (scrollable != null && scrollable != canvasHost + && !scrollable.isScrollableX() && !scrollable.isScrollableY()) scrollable = scrollable.getParent(); + if (scrollable == null || scrollable == canvasHost) return; + int edge = Math.max(24, Display.getInstance().convertToPixels(3)); + int stepX = Math.max(18, scrollable.getWidth() / 12); + int stepY = Math.max(18, scrollable.getHeight() / 12); + if (scrollable.isScrollableX()) { + if (x < scrollable.getAbsoluteX() + edge) { + scrollable.scrollRectToVisible(Math.max(0, scrollable.getScrollX() - stepX), + scrollable.getScrollY(), 1, 1, null); + } else if (x > scrollable.getAbsoluteX() + scrollable.getWidth() - edge) { + scrollable.scrollRectToVisible(scrollable.getScrollX() + scrollable.getWidth() + stepX, + scrollable.getScrollY(), 1, 1, null); + } + } + if (scrollable.isScrollableY()) { + if (y < scrollable.getAbsoluteY() + edge) { + scrollable.scrollRectToVisible(scrollable.getScrollX(), + Math.max(0, scrollable.getScrollY() - stepY), 1, 1, null); + } else if (y > scrollable.getAbsoluteY() + scrollable.getHeight() - edge) { + scrollable.scrollRectToVisible(scrollable.getScrollX(), + scrollable.getScrollY() + scrollable.getHeight() + stepY, 1, 1, null); + } + } + } + + private Component deepestComponentAt(Container root, int x, int y) { + for (int i = root.getComponentCount() - 1; i >= 0; i--) { + Component component = root.getComponentAt(i); + if (x < component.getAbsoluteX() || x > component.getAbsoluteX() + component.getWidth() + || y < component.getAbsoluteY() || y > component.getAbsoluteY() + component.getHeight()) continue; + if (component instanceof Container) { + Component nested = deepestComponentAt(((Container) component), x, y); + if (nested != null) return nested; + } + return component; + } + return null; + } + + private void refreshInspector() { + inspectorHost.removeAll(); + Element selected = document.selected(); + Tabs tabs = new Tabs(); + tabs.setSwipeActivated(false); + tabs.setUIID("BuilderInspectorTabs"); + tabs.addTab("Properties", propertiesTab(selected)); + tabs.addTab("Layout", layoutTab(selected)); + tabs.addTab("Events", eventsTab(selected)); + tabs.setSelectedIndex(Math.max(0, Math.min(2, inspectorTabIndex)), false); + tabs.addSelectionListener((oldIndex, newIndex) -> inspectorTabIndex = newIndex); + inspectorHost.add(BorderLayout.CENTER, tabs); + inspectorHost.revalidate(); + } + + private Component propertiesTab(Element element) { + Container fields = inspectorFields(); + String type = document.attribute("type", "Component"); + fields.add(new Label(type, "BuilderInspectorComponent")); + fields.add(nameField()); + if (isFormLike(type)) { + fields.add(propertyField("Title", "title")); + fields.add(bindingStrategyPicker()); + } + else if (hasText(type)) fields.add(propertyField("Text", "text")); + if ("TextField".equals(type) || "TextArea".equals(type)) fields.add(propertyField("Hint", "hint")); + fields.add(propertyField("UIID (CSS selector)", "uiid", document.effectiveUiid(element))); + fields.add(booleanProperty("Enabled", "enabled", true)); + fields.add(booleanProperty("Visible", "visible", true)); + fields.add(booleanProperty("Right-to-left", "rtl", false)); + if (hasText(type)) { + fields.add(propertyField("Icon/text gap", "gap", "2")); + fields.add(pickerProperty("Alignment", "alignment", new String[]{"left", "center", "right"}, "left")); + fields.add(booleanProperty("Ticker when clipped", "tickerEnabled", false)); + } + if ("Button".equals(type)) fields.add(booleanProperty("Toggle button", "toggle", false)); + if ("CheckBox".equals(type) || "RadioButton".equals(type)) fields.add(booleanProperty("Selected", "selected", false)); + if ("TextField".equals(type) || "TextArea".equals(type)) { + fields.add(propertyField("Columns", "columns", "12")); + fields.add(propertyField("Maximum length", "maxSize", "0")); + fields.add(booleanProperty("Editable", "editable", true)); + fields.add(booleanProperty("Grow by content", "growByContent", true)); + fields.add(pickerProperty("Input constraint", "constraint", + new String[]{"ANY", "EMAILADDR", "PASSWORD", "NUMERIC", "URL"}, "ANY")); + } + if ("TextArea".equals(type)) fields.add(propertyField("Rows", "rows", "3")); + if ("Slider".equals(type)) { + fields.add(propertyField("Minimum", "minValue", "0")); + fields.add(propertyField("Maximum", "maxValue", "100")); + fields.add(propertyField("Progress", "progress", "50")); + fields.add(booleanProperty("Editable", "editable", false)); + fields.add(booleanProperty("Infinite progress", "infinite", false)); + } + if ("Tabs".equals(type)) { + fields.add(propertyField("Selected tab index", "selectedIndex", "0")); + fields.add(pickerProperty("Tab placement", "tabPlacement", + new String[]{"top", "bottom", "left", "right"}, "top")); + } + if (GuiDocument.acceptsChildren(element)) { + fields.add(booleanProperty("Scroll horizontally", "scrollableX", false)); + fields.add(booleanProperty("Scroll vertically", "scrollableY", false)); + } + if (hasText(type) || isFormLike(type)) { + Button inline = new Button("Edit content in place", material(FontImage.MATERIAL_EDIT, "BuilderInlineIcon")); + inline.setUIID("BuilderSecondaryAction"); + inline.addActionListener(e -> editSelectedContent()); + fields.add(inline); + } + if (isFormLike(type)) { + fields.add(fieldLabel("Toolbar commands")); + fields.add(toolbarCommandsEditor()); + } + Button css = new Button("Edit theme.css", material(FontImage.MATERIAL_COLOR_LENS, "BuilderInlineIcon")); + css.setUIID("BuilderSecondaryAction"); + css.addActionListener(e -> openCss()); + fields.add(css); + Button delete = new Button("Delete component", material(FontImage.MATERIAL_DELETE, "BuilderDangerIcon")); + delete.setUIID("BuilderDangerAction"); + delete.setEnabled(element != document.root()); + delete.addActionListener(e -> { if (document.deleteSelected()) refreshEditor(); }); + fields.add(delete); + return fields; + } + + private Component bindingStrategyPicker() { + Container field = new Container(BoxLayout.y()); + field.add(fieldLabel("Data binding")); + String strategy = value(document.root(), "bindingStrategy", "properties"); + String selected = "bindable".equals(strategy) ? "@Bindable POJO" + : "none".equals(strategy) ? "None" : "PropertyBusinessObject"; + Picker picker = stringPicker(new String[]{"None", "PropertyBusinessObject", "@Bindable POJO"}, selected); + picker.addActionListener(e -> { + String choice = picker.getSelectedString(); + document.select(document.root()); + document.setAttribute("bindingStrategy", "None".equals(choice) ? "none" + : "@Bindable POJO".equals(choice) ? "bindable" : "properties"); + setStatus("Binding strategy: " + choice + " • Java preview regenerated on open"); + }); + field.add(picker); + return field; + } + + private Component toolbarCommandsEditor() { + Container commands = new Container(BoxLayout.y()); + for (Element command : document.commands()) { + Container row = new Container(BoxLayout.y()); + row.setUIID("BuilderCommandCard"); + TextField name = new TextField(value(command, "name", "Command")); + name.setHint("Command label"); + name.addDataChangedListener((t, i) -> document.setCommandAttribute(command, "name", name.getText())); + Picker placement = stringPicker(new String[]{"left", "right", "overflow", "side"}, value(command, "placement", "right")); + placement.addActionListener(e -> { + document.setCommandAttribute(command, "placement", placement.getSelectedString()); + refreshEditor(); + }); + TextField event = new TextField(value(command, "actionEvent", "onCommand")); + event.setHint("Event handler"); + event.addDataChangedListener((t, i) -> document.setCommandAttribute(command, "actionEvent", event.getText())); + Button remove = new Button("Remove command"); + remove.setUIID("BuilderDangerAction"); + remove.addActionListener(e -> { document.removeCommand(command); refreshEditor(); }); + row.add(name).add(placement).add(event).add(remove); + commands.add(row); + } + Button add = new Button("Add toolbar command", material(FontImage.MATERIAL_ADD, "BuilderInlineIcon")); + add.setUIID("BuilderSecondaryAction"); + add.addActionListener(e -> { document.addCommand(); refreshEditor(); }); + commands.add(add); + return commands; + } + + private Component layoutTab(Element element) { + Container fields = inspectorFields(); + if (element != document.root()) { + Container order = new Container(new GridLayout(1, 2)); + Button earlier = new Button("Move earlier", material(FontImage.MATERIAL_ARROW_UPWARD, "BuilderInlineIcon")); + Button later = new Button("Move later", material(FontImage.MATERIAL_ARROW_DOWNWARD, "BuilderInlineIcon")); + earlier.setUIID("BuilderSecondaryAction"); + later.setUIID("BuilderSecondaryAction"); + earlier.addActionListener(e -> moveSelectedInParent(-1)); + later.addActionListener(e -> moveSelectedInParent(1)); + order.add(earlier).add(later); + fields.add(fieldLabel("Order in parent")); + fields.add(order); + } + if (GuiDocument.acceptsChildren(element)) { + Picker layout = stringPicker(new String[]{"BoxLayout", "BorderLayout", "FlowLayout", "GridLayout", "TableLayout", "LayeredLayout"}, document.attribute("layout", "BoxLayout")); + layout.addActionListener(e -> update("layout", String.valueOf(layout.getSelectedString()))); + fields.add(fieldLabel("Container layout")); + fields.add(layout); + if ("BoxLayout".equals(document.attribute("layout", "BoxLayout"))) { + Picker axis = stringPicker(new String[]{"Y", "X"}, document.attribute("boxLayoutAxis", "Y")); + axis.addActionListener(e -> update("boxLayoutAxis", axis.getSelectedString())); + fields.add(fieldLabel("Axis")); + fields.add(axis); + } else if ("GridLayout".equals(document.attribute("layout", "BoxLayout"))) { + fields.add(numericPropertyField("Grid rows", "gridLayoutRows", "1", 1, 100)); + fields.add(numericPropertyField("Grid columns", "gridLayoutColumns", "2", 1, 100)); + } else if ("TableLayout".equals(document.attribute("layout", "BoxLayout"))) { + fields.add(numericPropertyField("Table rows", "tableLayoutRows", "2", 1, 100)); + fields.add(numericPropertyField("Table columns", "tableLayoutColumns", "2", 1, 100)); + } + } + Picker constraint = stringPicker(new String[]{"", "North", "South", "East", "West", "Center"}, document.attribute("layoutConstraint", "")); + constraint.addActionListener(e -> update("layoutConstraint", constraint.getSelectedString())); + fields.add(fieldLabel("Parent constraint")); + fields.add(constraint); + if ("LayeredLayout".equals(document.parentLayout(element))) { + Element referenceElement = guidedReferenceElement(element); + fields.add(fieldLabel("Alignment reference")); + Picker reference = stringPicker(guidedReferenceNames(element), referenceElement == null + ? "Nearest component" : value(referenceElement, "name", "Nearest component")); + reference.addActionListener(e -> { + String selected = reference.getSelectedString(); + document.setAttribute("guidedReferenceTarget", "Nearest component".equals(selected) ? null : selected); + }); + fields.add(reference); + + fields.add(fieldLabel("Horizontal size policy")); + Picker horizontalPolicy = stringPicker(new String[]{"Preferred", "Fixed", "Fill parent", "Match reference"}, + policyLabel(GuidedLayoutSupport.horizontalPolicy(element))); + horizontalPolicy.addActionListener(e -> applyGuidedSizePolicy(true, policyValue(horizontalPolicy.getSelectedString()))); + fields.add(horizontalPolicy); + fields.add(fieldLabel("Vertical size policy")); + Picker verticalPolicy = stringPicker(new String[]{"Preferred", "Fixed", "Fill parent", "Match reference"}, + policyLabel(GuidedLayoutSupport.verticalPolicy(element))); + verticalPolicy.addActionListener(e -> applyGuidedSizePolicy(false, policyValue(verticalPolicy.getSelectedString()))); + fields.add(verticalPolicy); + + fields.add(fieldLabel("Align to reference")); + Container align = new Container(new GridLayout(2, 3)); + align.add(layeredAction("Left", "alignLeft")); + align.add(layeredAction("H center", "alignHCenter")); + align.add(layeredAction("Right", "alignRight")); + align.add(layeredAction("Top", "alignTop")); + align.add(layeredAction("Baseline", "alignBaseline")); + align.add(layeredAction("Bottom", "alignBottom")); + fields.add(align); + Container actions = new Container(new GridLayout(2, 2)); + actions.add(layeredAction("Same width", "matchWidth")); + actions.add(layeredAction("Same height", "matchHeight")); + actions.add(layeredAction("Fill width", "fillWidth")); + actions.add(layeredAction("Fill height", "fillHeight")); + fields.add(actions); + fields.add(propertyField("Advanced insets (top right bottom left)", "layeredInsets", "auto auto auto auto")); + } else if ("TableLayout".equals(document.parentLayout(element))) { + fields.add(numericPropertyField("Table row", "tableRow", "0", 0, 99)); + fields.add(numericPropertyField("Table column", "tableColumn", "0", 0, 99)); + fields.add(numericPropertyField("Horizontal span", "tableHorizontalSpan", "1", 1, 100)); + fields.add(numericPropertyField("Vertical span", "tableVerticalSpan", "1", 1, 100)); + fields.add(numericPropertyField("Column width %", "tableWidth", "-1", -1, 100)); + fields.add(numericPropertyField("Row height %", "tableHeight", "-1", -1, 100)); + } + fields.add(new SpanLabel("Guided Layout is the default free-form designer. Drag edges or corners to resize. Blue guides align edges and centers; the baseline guide keeps text aligned. Size policies remain responsive when the form changes size.", "BuilderHelp")); + return fields; + } + + private Button layeredAction(String label, String action) { + Button button = new Button(label); + button.setUIID("BuilderSecondaryAction"); + button.addActionListener(e -> applyLayeredAction(action)); + return button; + } + + void applySelectionAction(String action) { + normalizeSelection(); + if (selectedElements.isEmpty() || !selectedElementsShareGuidedParent()) { + setStatus("Layout actions require components in the same Guided Layout container"); + return; + } + Element anchor = document.selected(); + if (anchor == null || !selectedElements.contains(anchor)) return; + if (!"disconnect".equals(action) && selectedElements.size() < 2) return; + document.beginTransaction(); + try { + if ("disconnect".equals(action)) { + for (Element element : new ArrayList(selectedElements)) disconnectGuidedElement(element); + } else { + for (Element element : new ArrayList(selectedElements)) { + if (element != anchor) applyLayeredRelationship(element, anchor, action); + } + } + document.select(anchor); + } finally { + document.endTransaction(); + } + recordAction("multi_selection_action", "action", action, "count", Integer.valueOf(selectedElements.size()), + "anchor", value(anchor, "name", "component")); + setStatus("disconnect".equals(action) ? "Disconnected " + selectedElements.size() + " components from layout relationships" + : labelForLayeredAction(action) + " using reference " + value(anchor, "name", "component") + + " across " + selectedElements.size() + " components"); + if (workspace != null) refreshEditor(); + } + + private void applyLayeredRelationship(Element element, Element referenceElement, String action) { + Component component = componentForElement(canvasHost, element); + applyLayeredRelationship(document, element, referenceElement, action, component); + } + + private void applyLayeredRelationship(GuiDocument targetDocument, Element element, + Element referenceElement, String action, Component component) { + if (component == null || referenceElement == null) return; + String[] insets = GuidedLayoutSupport.insetValues(element); + String[] refs = GuidedLayoutSupport.referenceNames(element); + String[] positions = GuidedLayoutSupport.referencePositions(element); + String referenceName = value(referenceElement, "name", "-"); + targetDocument.select(element); + if ("alignLeft".equals(action)) { + insets[3] = "0px"; insets[1] = "auto"; refs[3] = referenceName; refs[1] = "-"; positions[3] = "0"; + targetDocument.setAttribute("guidedHorizontalAnchor", null); + } else if ("alignHCenter".equals(action)) { + insets[3] = "0%"; insets[1] = "auto"; refs[3] = referenceName; refs[1] = "-"; positions[3] = "0.5"; + targetDocument.setAttribute("guidedHorizontalAnchor", "0.5"); + } else if ("alignRight".equals(action)) { + insets[3] = "auto"; insets[1] = "0px"; refs[3] = "-"; refs[1] = referenceName; positions[1] = "0"; + targetDocument.setAttribute("guidedHorizontalAnchor", null); + } else if ("alignTop".equals(action)) { + insets[0] = "0px"; insets[2] = "auto"; refs[0] = referenceName; refs[2] = "-"; positions[0] = "0"; + targetDocument.setAttribute("guidedVerticalAnchor", null); + } else if ("alignBaseline".equals(action)) { + insets[0] = "baseline"; insets[2] = "auto"; refs[0] = referenceName; refs[2] = "-"; positions[0] = "0"; + targetDocument.setAttribute("guidedVerticalSize", GuidedLayoutSupport.PREFERRED); + targetDocument.setAttribute("guidedPreferredHeight", null); + } else if ("alignBottom".equals(action)) { + insets[0] = "auto"; insets[2] = "0px"; refs[0] = "-"; refs[2] = referenceName; positions[2] = "0"; + targetDocument.setAttribute("guidedVerticalAnchor", null); + } else if ("matchWidth".equals(action)) { + applyMatchedWidth(referenceElement, relationshipReferencePreview(targetDocument, referenceElement), + insets, refs, positions, component.getAbsoluteX(), component.getWidth()); + targetDocument.setAttribute("guidedMatchWidth", referenceName); + targetDocument.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.MATCH); + } else if ("matchHeight".equals(action)) { + applyMatchedHeight(referenceElement, relationshipReferencePreview(targetDocument, referenceElement), + insets, refs, positions, component.getAbsoluteY(), component.getHeight()); + targetDocument.setAttribute("guidedMatchHeight", referenceName); + targetDocument.setAttribute("guidedVerticalSize", GuidedLayoutSupport.MATCH); + } + targetDocument.setAttribute("layeredInsets", GuidedLayoutSupport.joinInsets(insets[0], insets[1], insets[2], insets[3])); + targetDocument.setAttribute("guidedReferences", GuidedLayoutSupport.joinReferences(refs[0], refs[1], refs[2], refs[3])); + targetDocument.setAttribute("guidedReferencePositions", GuidedLayoutSupport.joinPositions( + positions[0], positions[1], positions[2], positions[3])); + } + + private Component relationshipReferencePreview(GuiDocument targetDocument, Element referenceElement) { + Element liveReference = targetDocument == document ? referenceElement + : findElementNamed(document, value(referenceElement, "name", "")); + return componentForElement(canvasHost, liveReference); + } + + private void disconnectGuidedElement(Element element) { + Component component = componentForElement(canvasHost, element); + Element parentElement = document.parentOf(element); + Component parentComponent = componentForElement(canvasHost, parentElement); + if (component == null || !(parentComponent instanceof Container)) return; + freezeGuidedElement(element, ((Container) parentComponent), component, component.getAbsoluteX(), component.getAbsoluteY()); + } + + private void freezeGuidedElement(Element element, Container parent, Component component, int absoluteX, int absoluteY) { + int left = Math.max(0, absoluteX - contentX(parent) - component.getStyle().getMarginLeftNoRTL()); + int top = Math.max(0, absoluteY - contentY(parent) - component.getStyle().getMarginTop()); + document.select(element); + document.setAttribute("layeredInsets", top + "px auto auto " + left + "px"); + document.setAttribute("guidedReferences", "- - - -"); + document.setAttribute("guidedReferencePositions", "0 0 0 0"); + document.setAttribute("guidedHorizontalAnchor", null); + document.setAttribute("guidedVerticalAnchor", null); + document.setAttribute("guidedMatchWidth", null); + document.setAttribute("guidedMatchHeight", null); + document.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.FIXED); + document.setAttribute("guidedVerticalSize", GuidedLayoutSupport.FIXED); + document.setAttribute("guidedPreferredWidth", String.valueOf(Math.max(1, component.getWidth()))); + document.setAttribute("guidedPreferredHeight", String.valueOf(Math.max(1, component.getHeight()))); + } + + private void moveSelectedInParent(int delta) { + if (reorderSelectedInParent(delta)) refreshEditor(); + } + + boolean reorderSelectedInParent(int delta) { + Element selected = document.selected(); + Element parent = document.parentOf(selected); + boolean moved = false; + document.beginTransaction(); + try { + // In a table the neighbour must be identified before the move, because "the component + // one step earlier" is the one whose cell the user wants; afterwards it is the + // selection itself that sits at that index. + Element neighbour = parent != null && "TableLayout".equals(value(parent, "layout", "BoxLayout")) + ? siblingBy(parent, selected, delta) : null; + if (!document.moveSelectedBy(delta)) return false; + moved = true; + if (parent != null && "TableLayout".equals(value(parent, "layout", "BoxLayout"))) { + // Swap the two cells rather than renumbering the table from sibling order: the + // user asked these two components to trade places, not the rest to shuffle. + if (neighbour != null) swapTableCells(selected, neighbour); + normalizeTableCells(parent); + document.select(selected); + } + } finally { + document.endTransaction(); + } + return moved; + } + + private Element siblingBy(Element parent, Element element, int delta) { + List children = componentChildren(parent); + int index = children.indexOf(element) + delta; + return index < 0 || index >= children.size() ? null : children.get(index); + } + + private void swapTableCells(Element first, Element second) { + String firstRow = first.getAttribute("tableRow"); + String firstColumn = first.getAttribute("tableColumn"); + document.select(first); + document.setAttribute("tableRow", second.getAttribute("tableRow")); + document.setAttribute("tableColumn", second.getAttribute("tableColumn")); + document.select(second); + document.setAttribute("tableRow", firstRow); + document.setAttribute("tableColumn", firstColumn); + } + + private void applyLayeredAction(String action) { + Element element = document.selected(); + Element parentElement = document.parentOf(element); + Component component = componentForElement(canvasHost, element); + Component parentComponent = componentForElement(canvasHost, parentElement); + if (component == null || !(parentComponent instanceof Container)) return; + int leftPx = component.getAbsoluteX() - ((Container) parentComponent).getAbsoluteX(); + int topPx = component.getAbsoluteY() - ((Container) parentComponent).getAbsoluteY(); + int width = component.getWidth(); + int height = component.getHeight(); + Component reference = guidedReferenceComponent(element, ((Container) parentComponent), component); + Element referenceElement = reference == null ? null : (Element) reference.getClientProperty("gui.element"); + if ((action.startsWith("align") || action.startsWith("match")) && referenceElement == null) { + setStatus("Add another component before creating an alignment relationship"); + return; + } + String[] insets = GuidedLayoutSupport.insetValues(element); + String[] refs = GuidedLayoutSupport.referenceNames(element); + String[] positions = GuidedLayoutSupport.referencePositions(element); + String referenceName = referenceElement == null ? "-" : value(referenceElement, "name", "-"); + document.beginTransaction(); + try { + if ("alignLeft".equals(action)) { + insets[3] = "0px"; insets[1] = "auto"; refs[3] = referenceName; refs[1] = "-"; positions[3] = "0"; + document.setAttribute("guidedHorizontalAnchor", null); + } else if ("alignHCenter".equals(action)) { + insets[3] = "0%"; insets[1] = "auto"; refs[3] = referenceName; refs[1] = "-"; positions[3] = "0.5"; + document.setAttribute("guidedHorizontalAnchor", "0.5"); + } else if ("alignRight".equals(action)) { + insets[3] = "auto"; insets[1] = "0px"; refs[3] = "-"; refs[1] = referenceName; positions[1] = "0"; + document.setAttribute("guidedHorizontalAnchor", null); + } else if ("alignTop".equals(action)) { + insets[0] = "0px"; insets[2] = "auto"; refs[0] = referenceName; refs[2] = "-"; positions[0] = "0"; + document.setAttribute("guidedVerticalAnchor", null); + } else if ("alignBaseline".equals(action)) { + insets[0] = "baseline"; insets[2] = "auto"; refs[0] = referenceName; refs[2] = "-"; positions[0] = "0"; + document.setAttribute("guidedVerticalSize", GuidedLayoutSupport.PREFERRED); + document.setAttribute("guidedPreferredHeight", null); + } else if ("alignBottom".equals(action)) { + insets[0] = "auto"; insets[2] = "0px"; refs[0] = "-"; refs[2] = referenceName; positions[2] = "0"; + document.setAttribute("guidedVerticalAnchor", null); + } else if ("matchWidth".equals(action)) { + applyMatchedWidth(referenceElement, insets, refs, positions, component.getAbsoluteX(), component.getWidth()); + document.setAttribute("guidedMatchWidth", referenceName); + document.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.MATCH); + } else if ("matchHeight".equals(action)) { + applyMatchedHeight(referenceElement, insets, refs, positions, component.getAbsoluteY(), component.getHeight()); + document.setAttribute("guidedMatchHeight", referenceName); + document.setAttribute("guidedVerticalSize", GuidedLayoutSupport.MATCH); + } else if ("fillWidth".equals(action)) { + insets[3] = Math.max(0, component.getAbsoluteX() - contentX(((Container) parentComponent))) + "px"; + insets[1] = Math.max(0, contentX(((Container) parentComponent)) + contentWidth(((Container) parentComponent)) - component.getAbsoluteX() - component.getWidth()) + "px"; + refs[3] = refs[1] = "-"; + document.setAttribute("guidedHorizontalSize", GuidedLayoutSupport.FILL); + } else if ("fillHeight".equals(action)) { + insets[0] = Math.max(0, component.getAbsoluteY() - contentY(((Container) parentComponent))) + "px"; + insets[2] = Math.max(0, contentY(((Container) parentComponent)) + contentHeight(((Container) parentComponent)) - component.getAbsoluteY() - component.getHeight()) + "px"; + refs[0] = refs[2] = "-"; + document.setAttribute("guidedVerticalSize", GuidedLayoutSupport.FILL); + } + document.setAttribute("layeredInsets", GuidedLayoutSupport.joinInsets(insets[0], insets[1], insets[2], insets[3])); + document.setAttribute("guidedReferences", GuidedLayoutSupport.joinReferences(refs[0], refs[1], refs[2], refs[3])); + document.setAttribute("guidedReferencePositions", GuidedLayoutSupport.joinPositions(positions[0], positions[1], positions[2], positions[3])); + } finally { + document.endTransaction(); + } + setStatus(labelForLayeredAction(action) + (reference == null ? "" : " using " + reference.getName().replace("preview.", ""))); + refreshEditor(); + } + + private void applyGuidedSizePolicy(boolean horizontal, String policy) { + Element element = document.selected(); + Element parentElement = document.parentOf(element); + Component component = componentForElement(canvasHost, element); + Component parentComponent = componentForElement(canvasHost, parentElement); + if (component == null || !(parentComponent instanceof Container)) return; + Component reference = guidedReferenceComponent(element, ((Container) parentComponent), component); + Element referenceElement = reference == null ? null : (Element) reference.getClientProperty("gui.element"); + if (GuidedLayoutSupport.MATCH.equals(policy) && referenceElement == null) { + setStatus("Match size needs another component as its reference"); + return; + } + String[] insets = GuidedLayoutSupport.insetValues(element); + String[] refs = GuidedLayoutSupport.referenceNames(element); + String[] positions = GuidedLayoutSupport.referencePositions(element); + document.beginTransaction(); + try { + if (horizontal) { + if (GuidedLayoutSupport.PREFERRED.equals(policy) || GuidedLayoutSupport.FIXED.equals(policy)) { + insets[3] = Math.max(0, component.getAbsoluteX() - contentX(((Container) parentComponent))) + "px"; + insets[1] = "auto"; refs[3] = refs[1] = "-"; + document.setAttribute("guidedPreferredWidth", GuidedLayoutSupport.FIXED.equals(policy) + ? String.valueOf(component.getWidth()) : null); + } else if (GuidedLayoutSupport.FILL.equals(policy)) { + insets[3] = Math.max(0, component.getAbsoluteX() - contentX(((Container) parentComponent))) + "px"; + insets[1] = Math.max(0, contentX(((Container) parentComponent)) + contentWidth(((Container) parentComponent)) - component.getAbsoluteX() - component.getWidth()) + "px"; + refs[3] = refs[1] = "-"; + } else { + applyMatchedWidth(referenceElement, insets, refs, positions, component.getAbsoluteX(), component.getWidth()); + document.setAttribute("guidedMatchWidth", value(referenceElement, "name", "")); + } + document.setAttribute("guidedHorizontalSize", policy); + } else { + if (GuidedLayoutSupport.PREFERRED.equals(policy) || GuidedLayoutSupport.FIXED.equals(policy)) { + insets[0] = Math.max(0, component.getAbsoluteY() - contentY(((Container) parentComponent))) + "px"; + insets[2] = "auto"; refs[0] = refs[2] = "-"; + document.setAttribute("guidedPreferredHeight", GuidedLayoutSupport.FIXED.equals(policy) + ? String.valueOf(component.getHeight()) : null); + } else if (GuidedLayoutSupport.FILL.equals(policy)) { + insets[0] = Math.max(0, component.getAbsoluteY() - contentY(((Container) parentComponent))) + "px"; + insets[2] = Math.max(0, contentY(((Container) parentComponent)) + contentHeight(((Container) parentComponent)) - component.getAbsoluteY() - component.getHeight()) + "px"; + refs[0] = refs[2] = "-"; + } else { + applyMatchedHeight(referenceElement, insets, refs, positions, component.getAbsoluteY(), component.getHeight()); + document.setAttribute("guidedMatchHeight", value(referenceElement, "name", "")); + } + document.setAttribute("guidedVerticalSize", policy); + } + document.setAttribute("layeredInsets", GuidedLayoutSupport.joinInsets(insets[0], insets[1], insets[2], insets[3])); + document.setAttribute("guidedReferences", GuidedLayoutSupport.joinReferences(refs[0], refs[1], refs[2], refs[3])); + document.setAttribute("guidedReferencePositions", GuidedLayoutSupport.joinPositions(positions[0], positions[1], positions[2], positions[3])); + } finally { + document.endTransaction(); + } + refreshEditor(); + setStatus((horizontal ? "Horizontal" : "Vertical") + " size policy: " + policyLabel(policy)); + } + + private String[] guidedReferenceNames(Element element) { + Element parent = document.parentOf(element); + List names = new ArrayList<>(); + names.add("Nearest component"); + for (Element child : componentChildren(parent)) if (child != element) names.add(value(child, "name", value(child, "type", "component"))); + return names.toArray(new String[names.size()]); + } + + private Element guidedReferenceElement(Element element) { + Element parent = document.parentOf(element); + return namedSibling(parent, value(element, "guidedReferenceTarget", "")); + } + + private Component guidedReferenceComponent(Element element, Container parent, Component component) { + Element explicit = guidedReferenceElement(element); + Component result = explicit == null ? null : componentForElement(canvasHost, explicit); + return result == null ? nearestSibling(parent, component) : result; + } + + private String policyLabel(String policy) { + if (GuidedLayoutSupport.FIXED.equals(policy)) return "Fixed"; + if (GuidedLayoutSupport.FILL.equals(policy)) return "Fill parent"; + if (GuidedLayoutSupport.MATCH.equals(policy)) return "Match reference"; + return "Preferred"; + } + + private String policyValue(String label) { + if ("Fixed".equals(label)) return GuidedLayoutSupport.FIXED; + if ("Fill parent".equals(label)) return GuidedLayoutSupport.FILL; + if ("Match reference".equals(label)) return GuidedLayoutSupport.MATCH; + return GuidedLayoutSupport.PREFERRED; + } + + private Component nearestSibling(Container parent, Component source) { + Component nearest = null; + long best = Long.MAX_VALUE; + int cx = source.getAbsoluteX() + source.getWidth() / 2; + int cy = source.getAbsoluteY() + source.getHeight() / 2; + for (int i = 0; i < parent.getComponentCount(); i++) { + Component candidate = parent.getComponentAt(i); + if (candidate == source || candidate.getClientProperty("gui.element") == null) continue; + long dx = candidate.getAbsoluteX() + candidate.getWidth() / 2 - cx; + long dy = candidate.getAbsoluteY() + candidate.getHeight() / 2 - cy; + long distance = dx * dx + dy * dy; + if (distance < best) { best = distance; nearest = candidate; } + } + return nearest; + } + + private String percent(int pixels, int total) { + return Math.max(0, Math.min(100, Math.round(pixels * 100f / Math.max(1, total)))) + "%"; + } + + private String labelForLayeredAction(String action) { + if ("alignLeft".equals(action)) return "Aligned left edges"; + if ("alignHCenter".equals(action)) return "Aligned horizontal centers"; + if ("alignRight".equals(action)) return "Aligned right edges"; + if ("alignTop".equals(action)) return "Aligned top edges"; + if ("alignBaseline".equals(action)) return "Aligned text baselines"; + if ("alignBottom".equals(action)) return "Aligned bottom edges"; + if ("fillWidth".equals(action)) return "Filled available width"; + if ("fillHeight".equals(action)) return "Filled available height"; + if ("matchWidth".equals(action)) return "Matched nearest width"; + return "Matched nearest height"; + } + + private Component eventsTab(Element element) { + Container fields = inspectorFields(); + fields.add(propertyField("Action handler", "actionEvent")); + fields.add(new SpanLabel("Create or edit the selected handler here. The embedded Java editor keeps generated UI code separate and adds a handler stub only when it is missing.", "BuilderHelp")); + Button code = new Button("Edit event handler", material(FontImage.MATERIAL_CODE, "BuilderInlineIcon")); + code.setUIID("BuilderPrimaryAction"); + code.addActionListener(e -> openEventHandler()); + fields.add(code); + return fields; + } + + private Container inspectorFields() { + Container fields = new Container(BoxLayout.y()); + fields.setUIID("BuilderInspectorFields"); + fields.setScrollableY(true); + return fields; + } + + private Component propertyField(String label, String attribute) { + return propertyField(label, attribute, document.attribute(attribute, "")); + } + + /** + * The component name is the identity that Guided Layout relationships and generated Java + * fields are built from. It is committed on Enter or focus loss rather than on every + * keystroke, is forced to stay unique, and repoints every relationship that referenced the + * previous name in one undo step. + */ + private Component nameField() { + Element target = document.selected(); + TextField field = new TextField(value(target, "name", "")); + field.setUIID("BuilderField"); + field.getSemantics().setIdentifier("guibuilder.property.name").setLabel("Name"); + Runnable commit = () -> { + if (document == null || !document.containsElement(target)) return; + String previous = value(target, "name", ""); + String requested = field.getText() == null ? "" : field.getText().trim(); + if (requested.equals(previous)) return; + if (requested.length() == 0) { + field.setText(previous); + setStatus("A component name cannot be empty"); + return; + } + document.select(target); + String applied = document.renameSelected(requested); + recordAction("property_changed", "component", previous, "property", "name", "value", applied); + if (applied != null && !applied.equals(requested)) { + field.setText(applied); + setStatus("Renamed to " + applied + " because " + requested + " is already used"); + } else { + setStatus("Renamed " + previous + " to " + applied + " • relationships updated"); + } + scheduleDesignerRefresh(); + }; + field.addActionListener(e -> commit.run()); + field.addFocusListener(new FocusListener() { + @Override public void focusGained(Component component) { } + @Override public void focusLost(Component component) { commit.run(); } + }); + return BoxLayout.encloseY(fieldLabel("Name"), field); + } + + private Component propertyField(String label, String attribute, String fallback) { + Element target = document.selected(); + TextField field = new TextField(document.attribute(attribute, fallback)); + field.setUIID("BuilderField"); + field.getSemantics().setIdentifier("guibuilder.property." + attribute).setLabel(label); + field.addDataChangedListener((type, index) -> update(target, attribute, field.getText())); + return BoxLayout.encloseY(fieldLabel(label), field); + } + + private Component numericPropertyField(String label, String attribute, String fallback, int minimum, int maximum) { + Element target = document.selected(); + TextField field = new TextField(document.attribute(attribute, fallback)); + field.setUIID("BuilderField"); + field.getSemantics().setIdentifier("guibuilder.property." + attribute).setLabel(label); + Runnable validate = () -> { + Integer parsed = parseInteger(field.getText()); + boolean valid = parsed != null && parsed >= minimum && parsed <= maximum; + field.setUIID(valid ? "BuilderField" : "BuilderFieldError"); + field.repaint(); + }; + Runnable commit = () -> { + Integer parsed = parseInteger(field.getText()); + if (parsed == null || parsed < minimum || parsed > maximum) { + field.setText(document.attribute(attribute, fallback)); + field.setUIID("BuilderField"); + setStatus(label + " must be a number from " + minimum + " to " + maximum); + return; + } + String normalized = String.valueOf(parsed); + if (!normalized.equals(document.attribute(attribute, fallback))) update(target, attribute, normalized); + }; + field.addDataChangedListener((type, index) -> validate.run()); + field.addActionListener(e -> commit.run()); + field.addFocusListener(new com.codename1.ui.events.FocusListener() { + public void focusGained(Component component) { } + public void focusLost(Component component) { commit.run(); } + }); + return BoxLayout.encloseY(fieldLabel(label), field); + } + + static Integer parseInteger(String value) { + try { + if (value == null || value.length() == 0) return null; + int start = value.charAt(0) == '-' ? 1 : 0; + if (start == value.length()) return null; + for (int i = start; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch < '0' || ch > '9') return null; + } + return Integer.valueOf(value); + } catch (NumberFormatException ex) { + return null; + } + } + + private Component booleanProperty(String label, String attribute, boolean fallback) { + Element target = document.selected(); + CheckBox field = new CheckBox(label); + field.setUIID("BuilderCheck"); + field.getSemantics().setIdentifier("guibuilder.property." + attribute).setLabel(label); + field.setSelected("true".equals(document.attribute(attribute, String.valueOf(fallback)))); + field.addActionListener(e -> update(target, attribute, String.valueOf(field.isSelected()))); + return field; + } + + private Component pickerProperty(String label, String attribute, String[] values, String fallback) { + Element target = document.selected(); + Picker picker = stringPicker(values, document.attribute(attribute, fallback)); + picker.getSemantics().setIdentifier("guibuilder.property." + attribute).setLabel(label); + picker.addActionListener(e -> update(target, attribute, picker.getSelectedString())); + return BoxLayout.encloseY(fieldLabel(label), picker); + } + + private Label fieldLabel(String text) { return new Label(text, "BuilderFieldLabel"); } + + private Picker stringPicker(String[] values, String selected) { + Picker picker = new Picker(); + picker.setType(Display.PICKER_TYPE_STRINGS); + picker.setStrings(values); + picker.setSelectedString(selected); + picker.setUIID("BuilderPicker"); + return picker; + } + + private void update(String attribute, String value) { + update(document.selected(), attribute, value); + } + + private void update(Element element, String attribute, String value) { + document.select(element); + document.setAttribute(attribute, value); + recordAction("property_changed", "component", value(element, "name", value(element, "type", "component")), + "property", attribute, "value", value); + if ("layout".equals(attribute) || "boxLayoutAxis".equals(attribute) + || "gridLayoutRows".equals(attribute) || "gridLayoutColumns".equals(attribute) + || "tableLayoutRows".equals(attribute) || "tableLayoutColumns".equals(attribute) + || "tableRow".equals(attribute) || "tableColumn".equals(attribute) + || "tableHorizontalSpan".equals(attribute) || "tableVerticalSpan".equals(attribute) + || "tableWidth".equals(attribute) || "tableHeight".equals(attribute) + || "layoutConstraint".equals(attribute)) { + refreshEditor(); + } else { + updatePreviewAttribute(element, attribute, value); + } + setStatus("Modified • " + relativeFormName(document.path())); + } + + private void updatePreviewAttribute(Element element, String attribute, String value) { + if (element == document.root() && "title".equals(attribute) && formTitlePreview != null) { + formTitlePreview.setText(value == null || value.length() == 0 ? "Untitled Form" : value); + formTitlePreview.getParent().revalidate(); + return; + } + Component component = componentForElement(canvasHost, element); + if (component == null) return; + if ("text".equals(attribute)) { + if (component instanceof SpanLabel) ((SpanLabel) component).setText(value); + else if (component instanceof Label) ((Label) component).setText(value); + else if (component instanceof TextArea) ((TextArea) component).setText(value); + } else if ("hint".equals(attribute) && component instanceof TextArea) { + ((TextArea) component).setHint(value); + } else if ("enabled".equals(attribute)) { + component.setEnabled(!"false".equals(value)); + } else if ("visible".equals(attribute)) { + component.setVisible(!"false".equals(value)); + } else if ("rtl".equals(attribute)) { + component.setRTL("true".equals(value)); + } else if ("name".equals(attribute)) { + component.setName("preview." + value); + } else if ("gap".equals(attribute) && component instanceof Label) { + ((Label) component).setGap(integer(value, ((Label) component).getGap())); + } else if ("alignment".equals(attribute)) { + int alignment = "center".equals(value) ? Component.CENTER : "right".equals(value) ? Component.RIGHT : Component.LEFT; + if (component instanceof Label) ((Label) component).setAlignment(alignment); + else if (component instanceof TextArea) ((TextArea) component).setAlignment(alignment); + } else if ("tickerEnabled".equals(attribute) && component instanceof Label) { + ((Label) component).setTickerEnabled("true".equals(value)); + } else if ("toggle".equals(attribute) && component instanceof Button) { + ((Button) component).setToggle("true".equals(value)); + } else if ("selected".equals(attribute) && component instanceof CheckBox) { + ((CheckBox) component).setSelected("true".equals(value)); + } else if ("columns".equals(attribute) && component instanceof TextArea) { + ((TextArea) component).setColumns(integer(value, ((TextArea) component).getColumns())); + } else if ("rows".equals(attribute) && component instanceof TextArea) { + ((TextArea) component).setRows(integer(value, ((TextArea) component).getRows())); + } else if ("maxSize".equals(attribute) && component instanceof TextArea) { + ((TextArea) component).setMaxSize(integer(value, ((TextArea) component).getMaxSize())); + } else if ("editable".equals(attribute) && component instanceof TextArea) { + ((TextArea) component).setEditable(!"false".equals(value)); + } else if ("growByContent".equals(attribute) && component instanceof TextArea) { + ((TextArea) component).setGrowByContent(!"false".equals(value)); + } else if ("scrollableX".equals(attribute) && component instanceof Container) { + ((Container) component).setScrollableX("true".equals(value)); + } else if ("scrollableY".equals(attribute) && component instanceof Container) { + ((Container) component).setScrollableY("true".equals(value)); + } else if ("minValue".equals(attribute) && component instanceof com.codename1.ui.Slider) { + ((com.codename1.ui.Slider) component).setMinValue(integer(value, ((com.codename1.ui.Slider) component).getMinValue())); + } else if ("maxValue".equals(attribute) && component instanceof com.codename1.ui.Slider) { + ((com.codename1.ui.Slider) component).setMaxValue(integer(value, ((com.codename1.ui.Slider) component).getMaxValue())); + } else if ("progress".equals(attribute) && component instanceof com.codename1.ui.Slider) { + ((com.codename1.ui.Slider) component).setProgress(integer(value, ((com.codename1.ui.Slider) component).getProgress())); + } else if ("editable".equals(attribute) && component instanceof com.codename1.ui.Slider) { + ((com.codename1.ui.Slider) component).setEditable("true".equals(value)); + } else if ("infinite".equals(attribute) && component instanceof com.codename1.ui.Slider) { + ((com.codename1.ui.Slider) component).setInfinite("true".equals(value)); + } else if ("selectedIndex".equals(attribute) && component instanceof Tabs && ((Tabs) component).getTabCount() > 0) { + ((Tabs) component).setSelectedIndex(Math.max(0, Math.min(((Tabs) component).getTabCount() - 1, integer(value, 0))), false); + } else if ("tabPlacement".equals(attribute) && component instanceof Tabs) { + ((Tabs) component).setTabPlacement("bottom".equals(value) ? Component.BOTTOM : "left".equals(value) ? Component.LEFT + : "right".equals(value) ? Component.RIGHT : Component.TOP); + } else if ("layeredInsets".equals(attribute) && component.getParent() != null + && component.getParent().getLayout() instanceof LayeredLayout) { + try { ((LayeredLayout) component.getParent().getLayout()).setInsets(component, value); } catch (RuntimeException ignored) { } + } else if ("uiid".equals(attribute)) { + component.setUIID(value == null || value.length() == 0 ? document.effectiveUiid(element) : value); + refreshSinglePreviewTheme(component); + } + if (component.getParent() != null) component.getParent().revalidate(); + component.repaint(); + } + + private void refreshSinglePreviewTheme(Component component) { + try { + if (projectTheme != null) { + UIManager.getInstance().setThemeProps(projectTheme); + component.refreshTheme(); + } + } finally { + if (builderTheme != null) UIManager.getInstance().setThemeProps(builderTheme); + } + ComponentPreviewFactory.stabilizeDesignStyles(component); + } + + private int integer(String value, int fallback) { + try { return Integer.parseInt(value == null || value.length() == 0 ? String.valueOf(fallback) : value); } + catch (NumberFormatException ex) { return fallback; } + } + + private void addComponent(String type) { + if (document == null) return; + if (document.addComponent(type) == null) { + // Core BorderLayout evicts whatever holds the region it is handed, so a sixth child + // would remove one of the five already placed rather than joining them. + ToastBar.showErrorMessage("That container is a BorderLayout and all five regions are taken"); + setStatus("Nothing added: the selected BorderLayout is full"); + return; + } + refreshEditor(); + setStatus("Added " + type); + } + + private void editSelectedContent() { + if (document == null) return; + finishInlineEditor(); + Element target = document.selected(); + String type = value(target, "type", "Component"); + String attribute = isFormLike(type) ? "title" : "text"; + Component source = target == document.root() ? formTitlePreview : componentForElement(canvasHost, target); + if (source == null || workspace == null) return; + Container parent = source.getParent(); + if (parent == null) return; + final int editorWidth = Math.max(1, source.getWidth()); + final int editorHeight = Math.max(1, source.getHeight()); + TextField editor = new TextField(value(target, attribute, "")) { + @Override protected Dimension calcPreferredSize() { + Dimension natural = super.calcPreferredSize(); + natural.setWidth(Math.max(editorWidth, natural.getWidth())); + natural.setHeight(Math.max(editorHeight, natural.getHeight())); + return natural; + } + }; + editor.setUIID("BuilderInlineEditor"); + editor.setSingleLineTextArea(true); + editor.addDataChangedListener((changeType, index) -> { + document.select(target); + document.setAttribute(attribute, editor.getText()); + updatePreviewAttribute(target, attribute, editor.getText()); + setStatus("Editing " + attribute + " in place • Enter or click away to finish"); + }); + editor.setDoneListener(e -> finishInlineEditor()); + editor.addFocusListener(new FocusListener() { + @Override public void focusGained(Component cmp) { } + @Override public void focusLost(Component cmp) { + Display.getInstance().callSerially(() -> finishInlineEditor()); + } + }); + inlineEditor = editor; + inlineEditorSource = source; + inlineEditorParent = parent; + inlineEditorTarget = target; + inlineEditorAttribute = attribute; + parent.replace(source, editor, null); + parent.revalidate(); + Display.getInstance().callSerially(() -> { + if (inlineEditor == editor) { + editor.requestFocus(); + editor.startEditingAsync(); + } + }); + } + + private void finishInlineEditor() { + if (inlineEditor == null || finishingInlineEditor) return; + finishingInlineEditor = true; + TextField editor = inlineEditor; + inlineEditor = null; + Component source = inlineEditorSource; + Container parent = inlineEditorParent; + Element target = inlineEditorTarget; + String attribute = inlineEditorAttribute; + inlineEditorSource = null; + inlineEditorParent = null; + inlineEditorTarget = null; + inlineEditorAttribute = null; + Runnable complete = () -> completeInlineEditor(editor, source, parent, target, attribute); + if (editor.isEditing()) editor.stopEditing(() -> Display.getInstance().callSerially(complete)); + else complete.run(); + } + + private void completeInlineEditor(TextField editor, Component source, Container parent, + Element target, String attribute) { + if (parent != null && source != null && editor.getParent() == parent) { + parent.replace(editor, source, null); + } else if (editor.getParent() != null) { + Container actualParent = editor.getParent(); + actualParent.removeComponent(editor); + } + commitInlineValue(target, attribute, editor.getText()); + if (parent != null) parent.revalidate(); + finishingInlineEditor = false; + if (document != null && inspectorHost != null) refreshInspector(); + } + + void commitInlineValue(Element target, String attribute, String value) { + if (document == null || target == null || attribute == null) return; + document.select(target); + document.setAttribute(attribute, value); + updatePreviewAttribute(target, attribute, value); + } + + private void copy() { + if (document != null) clipboardXml = document.copySelectedXml(); + } + + private void undo() { + finishInlineEditor(); + if (document != null && document.undo()) { + refreshEditor(); + recordAction("undo", "form", relativeFormName(document.path())); + setStatus("Undo"); + } + } + + private void redo() { + finishInlineEditor(); + if (document != null && document.redo()) { + refreshEditor(); + recordAction("redo", "form", relativeFormName(document.path())); + setStatus("Redo"); + } + } + + private void cut() { + if (document == null || document.selected() == document.root()) return; + copy(); + document.deleteSelected(); + refreshEditor(); + } + + private void paste() { + if (document != null && document.pasteXml(clipboardXml) != null) refreshEditor(); + } + + private void deleteSelection() { + if (document != null && document.deleteSelected()) refreshEditor(); + } + + private void toggleDarkMode() { + darkMode = !darkMode; + Preferences.set("guibuilder.darkMode", darkMode); + Display.getInstance().setDarkMode(Boolean.valueOf(darkMode)); + UIManager.getInstance().setThemeProps(builderTheme); + workspace.refreshTheme(); + refreshEditor(); + } + + /** + * @return true when the form reached disk. Callers that are about to replace the in-memory + * document have to know: a read only file or a full disk otherwise discarded the edits the + * user had just asked to keep. + */ + private boolean save() { + if (document == null) return true; + try { + ProjectIO.write(document.path(), document.toXml()); + // The .gui file alone changes nothing at runtime. Without regenerating the companion + // here, a component added in the designer and saved was simply absent from the running + // application until the user happened to open the Code pane and save that too, and a + // freshly scaffolded form stayed an empty screen despite a successful save. + String sourcePath = companionSourcePath(); + String existing = ProjectIO.exists(sourcePath) ? ProjectIO.read(sourcePath) : null; + if (!writeCompanionSource(sourcePath, existing)) return false; + // The generated companion references

Model whenever a binding strategy is set, + // and cn1:create-gui-form does not write bindingStrategy, so a scaffolded form defaults + // to "properties". Creating the model only when the Code pane is saved left an ordinary + // Save producing source that referenced a class which did not exist. + ensureBindingModel(sourcePath); + // Only now: marking the document clean after the .gui write but before the companion + // one meant a failed companion write left isModified() false, so the next form switch + // went ahead without retrying and the runtime source stayed stale. + document.markSaved(); + recordAction("saved", "form", relativeFormName(document.path())); + setStatus("Saved " + relativeFormName(document.path()) + " and its companion source"); + ToastBar.showMessage("GUI form saved", FontImage.MATERIAL_CHECK); + return true; + } catch (IOException ex) { + ToastBar.showErrorMessage("Save failed: " + ex.getMessage()); + return false; + } + } + + private void refreshProject() { + guiFiles = ProjectIO.findGuiFiles(binding.guiDir()); + refreshForms(); + if (document == null) return; + // Re-reading the form from disk throws away everything edited since the last save. Refresh + // is about picking up files added outside the editor, so it must not cost the user work. + if (document.isModified() + && !com.codename1.ui.Dialog.show("Unsaved changes", + "Reloading " + relativeFormName(document.path()) + + " from disk discards the changes you have not saved.", "Reload", "Keep editing")) { + setStatus("Project refreshed; kept your unsaved changes"); + return; + } + openForm(document.path()); + } + + private void showEmptyProject() { + canvasHost.removeAll(); + canvasHost.add(BorderLayout.CENTER, new SpanLabel("No GUI Builder files were found under src/main/guibuilder. Create one with mvn cn1:create-gui-form -DclassName=com.example.MyForm.", "BuilderEmptyProject")); + canvasHost.revalidate(); + } + + private void setCanvasMode(String mode) { + canvasMode = mode; + refreshEditor(); + setStatus("Preview: " + mode.replace("phone", "phone ").replace("tablet", "tablet ")); + } + + private void openCss() { + if (binding == null || binding.cssFile() == null) return; + try { + refreshEditor(); + String css = ProjectIO.read(binding.cssFile()); + if (reopeningEditor && editorBuffer != null) css = editorBuffer; + CodeEditor editor = new CodeEditor("css", css); + activeCodeEditor = editor; + lastObservedCss = css; + // The CSS editor already observes its own text to recompile live, so it mirrors the + // buffer from that path instead of registering a second change listener here. + editorBuffer = css; + if (!reopeningEditor) editorBufferOnDisk = css; + editor.setTheme(darkMode ? "dark" : "light"); + editor.setEditable(true); + editor.setShowLineNumbers(true); + Component stage = canvasHost.getComponentAt(0); + // Name the file being edited. "theme.css" alone left it ambiguous which stylesheet the + // canvas was actually showing when the two did not appear to agree. + Container editorPane = editorPane(binding.cssFile(), editor, + () -> editor.getText(value -> saveCss(value, editor)), this::closeEditorPane); + activeEditorReopen = this::openCss; + editor.addChangeListener(event -> scheduleLiveCss(editor)); + if (cssLiveTimer != null) cssLiveTimer.cancel(); + cssLiveTimer = com.codename1.ui.util.UITimer.timer(250, true, workspace, () -> { + if (activeCodeEditor != editor || editor.getComponentForm() != workspace) { + if (cssLiveTimer != null) cssLiveTimer.cancel(); + return; + } + editor.getText(value -> { + if (value != null && !value.equals(lastObservedCss)) { + scheduleLiveCss(editor); + } + }); + }); + canvasHost.removeComponent(stage); + SplitPane split = new SplitPane(SplitPane.HORIZONTAL_SPLIT, editorPane, stage, "20%", "50%", "80%"); + canvasHost.removeAll(); + canvasHost.add(BorderLayout.CENTER, split); + canvasHost.revalidate(); + // Focus only once the editor is in the form: requestFocus does nothing before that. + editor.onReady(editor::focusEditor); + setStatus("Editing CSS • changes compile into the live preview"); + } catch (IOException ex) { + ToastBar.showErrorMessage("Unable to read theme.css: " + ex.getMessage()); + } + } + + private void scheduleLiveCss(CodeEditor editor) { + final int revision = ++cssEditRevision; + com.codename1.ui.util.UITimer.timer(120, false, workspace, () -> { + if (revision != cssEditRevision || activeCodeEditor != editor) return; + editor.getText(value -> { + if (value == null) return; + editorBuffer = value; + if (value.equals(lastObservedCss)) return; + lastObservedCss = value; + applyProjectCss(value, editor); + }); + }); + } + + private void openCompanionSource() { + openSourceEditor(null); + } + + private void openBindingModel() { + if (document == null || binding == null || binding.sourceDir() == null) return; + String formPath = companionSourcePath(); + String modelPath = formPath.substring(0, formPath.length() - 5) + "Model.java"; + try { + // Rebuild the canvas first: opening an editor over an already open one would nest + // split panes and push the design surface out of reach. + refreshEditor(); + String source = ProjectIO.exists(modelPath) ? ProjectIO.read(modelPath) : generatedModelSource(); + if (reopeningEditor && editorBuffer != null) source = editorBuffer; + CodeEditor editor = new CodeEditor("java", source); + activeCodeEditor = editor; + trackEditorBuffer(editor, source); + editor.setTheme(darkMode ? "dark" : "light"); + editor.setEditable(true); + editor.onReady(editor::focusEditor); + Component stage = canvasHost.getComponentCount() == 0 ? new Label() : canvasHost.getComponentAt(0); + Container editorPane = editorPane("Binding model: " + relativeFormName(document.path()) + "Model", + editor, () -> editor.getText(value -> saveSource(modelPath, value)), this::closeEditorPane); + activeEditorReopen = this::openBindingModel; + canvasHost.removeComponent(stage); + canvasHost.removeAll(); + canvasHost.add(BorderLayout.CENTER, + new SplitPane(SplitPane.HORIZONTAL_SPLIT, editorPane, stage, "20%", "58%", "85%")); + canvasHost.revalidate(); + setStatus("Editing generated PropertyBusinessObject binding model"); + } catch (IOException ex) { + ToastBar.showErrorMessage("Unable to open binding model: " + ex.getMessage()); + } + } + + private void openEventHandler() { + String handler = document == null ? "" : document.attribute("actionEvent", "").trim(); + if (handler.length() == 0) { + ToastBar.showErrorMessage("Enter an action handler name first"); + return; + } + openSourceEditor(handler); + } + + private void openSourceEditor(String handler) { + if (document == null || binding == null || binding.sourceDir() == null) return; + String sourcePath = companionSourcePath(); + try { + // Rebuild the canvas first: opening an editor over an already open one would nest + // split panes and push the design surface out of reach. + refreshEditor(); + String generated = defaultCompanionSource(); + String source = ProjectIO.exists(sourcePath) ? mergeGeneratedSource(ProjectIO.read(sourcePath), generated) : generated; + // A rebuild must not cost the user their unsaved text; the mirror is what they were + // actually looking at, the file is merely what was last written. + if (reopeningEditor && editorBuffer != null) source = editorBuffer; + for (String generatedHandler : generatedHandlers()) source = ensureHandler(source, generatedHandler); + // Normalized, because the stub and the listener are both generated from the normalized + // name. Passing the raw Events value here appended a second, invalid declaration for + // anything that is not already an identifier, so "on-click" opened source that could + // not compile and could then be saved in that state. + if (handler != null) source = ensureHandler(source, javaIdentifier(handler)); + CodeEditor editor = new CodeEditor("java", source); + activeCodeEditor = editor; + trackEditorBuffer(editor, source); + editor.setTheme(darkMode ? "dark" : "light"); + editor.setEditable(true); + // No protected regions here. Marking the generated blocks read-only meant the caret + // landing anywhere in them -- which is most of the file, and where it lands after a + // click -- refused every keystroke, so the editor read as completely dead. It bought + // nothing either: saveSourceAndModel keeps only the user region and regenerates the + // rest from the model, so edits to generated code are discarded on save regardless. + int userMarker = source.indexOf("// "); + final int editableOffset = userMarker < 0 ? 0 + : userMarker + "// ".length() + 1; + Component stage = canvasHost.getComponentCount() == 0 ? new Label() : canvasHost.getComponentAt(0); + final String reopenHandler = handler; + Container editorPane = editorPane(handler == null ? "Companion Java source • edit inside USER CODE markers" : "Handler: " + handler, editor, + () -> editor.getText(value -> saveSourceAndModel(sourcePath, value)), this::closeEditorPane); + activeEditorReopen = () -> openSourceEditor(reopenHandler); + canvasHost.removeComponent(stage); + SplitPane split = new SplitPane(SplitPane.HORIZONTAL_SPLIT, editorPane, stage, "20%", "58%", "85%"); + canvasHost.removeAll(); + canvasHost.add(BorderLayout.CENTER, split); + canvasHost.revalidate(); + // Only now: requestFocus does nothing for a component that is not yet in the form. + editor.onReady(() -> { + editor.setCursorPosition(editableOffset); + editor.focusEditor(); + }); + setStatus("Editing " + relativeFormName(document.path()) + " Java behavior" + + " - only the USER CODE region is kept on save"); + } catch (IOException ex) { + ToastBar.showErrorMessage("Unable to open source: " + ex.getMessage()); + } + } + + private Container editorPane(String title, CodeEditor editor, Runnable saveAction, Runnable closeAction) { + Container pane = new Container(new BorderLayout()); + pane.setUIID("BuilderEditorPane"); + Container actions = new Container(new BorderLayout()); + actions.setUIID("BuilderEditorToolbar"); + actions.add(BorderLayout.CENTER, new Label(title, "BuilderEditorTitle")); + Button close = new Button("Close"); + close.setUIID("BuilderSecondaryAction"); + close.addActionListener(e -> closeAction.run()); + Button save = new Button("Save"); + save.setUIID("BuilderPrimaryAction"); + save.addActionListener(e -> saveAction.run()); + actions.add(BorderLayout.WEST, close); + actions.add(BorderLayout.EAST, save); + pane.add(BorderLayout.NORTH, actions); + pane.add(BorderLayout.CENTER, editor); + return pane; + } + + private void saveCss(String css, CodeEditor editor) { + if (!applyProjectCss(css, editor)) return; + try { + ProjectIO.write(binding.cssFile(), css); + editorBufferOnDisk = css; + setStatus("Saved theme.css • preview updated"); + } catch (IOException ex) { + ToastBar.showErrorMessage("CSS save failed: " + ex.getMessage()); + } + } + + private boolean applyProjectCss(String css, CodeEditor editor) { + try { + MutableResource resources = new MutableResource(); + new CSSThemeCompiler().compile(css, resources, "ProjectTheme"); + projectTheme = resources.getTheme("ProjectTheme"); + normalizeCompiledTheme(projectTheme); + editor.setDiagnostics(new ArrayList()); + refreshProjectThemeOnPreview(); + setStatus("CSS compiled • live preview updated"); + return true; + } catch (RuntimeException ex) { + List diagnostics = new ArrayList<>(); + diagnostics.add(new CodeDiagnostic(1, 1, ex.getMessage() == null ? "CSS compile error" : ex.getMessage())); + editor.setDiagnostics(diagnostics); + setStatus("CSS error • fix the highlighted problem before saving"); + return false; + } + } + + private void refreshProjectThemeOnPreview() { + if (projectTheme == null) { + setStatus("Project CSS is not loaded - the canvas is showing the builder's theme"); + return; + } + // Install the project theme into the canvas's own UIManager and leave the global one alone. + // Swapping the global theme, refreshing, then swapping the builder's theme back meant the + // preview only held the project styling for the instant of that call: every later + // re-resolution -- a repaint, a revalidate, any refreshTheme cascade -- resolved against + // the builder chrome again, so CSS edits appeared to do nothing. + // A brand new manager each time rather than reinstalling props into the existing one. + // Installing into a live manager left components resolving styles it had already cached, + // so the canvas kept the previous look and the stylesheet appeared to have no effect. + themeApplyCount++; + previewUIManager = UIManager.createInstance(); + previewUIManager.setThemeProps(projectTheme); + if (deviceSurface != null) deviceSurface.setUIManager(previewUIManager); + if (previewRoot instanceof Container) { + ((Container) previewRoot).setUIManager(previewUIManager); + } + if (previewRoot != null) { + previewRoot.refreshTheme(false); + ComponentPreviewFactory.stabilizeDesignStyles(previewRoot); + } + if (formToolbarPreview != null) { + formToolbarPreview.refreshTheme(false); + ComponentPreviewFactory.stabilizeDesignStyles(formToolbarPreview); + } + if (canvasHost != null) { + canvasHost.revalidate(); + canvasHost.repaint(); + } + // Repaint the form as well. A component only repaints itself when it is already painting + // its own region, and the canvas sits inside a split pane whose parent decides the frame, + // so restyling without asking the form for a new frame left the old pixels on screen. + Form current = Display.getInstance().getCurrent(); + if (current != null) current.repaint(); + } + + private void loadProjectTheme() { + if (binding == null || binding.cssFile() == null) return; + try { + MutableResource resources = new MutableResource(); + new CSSThemeCompiler().compile(ProjectIO.read(binding.cssFile()), resources, "ProjectTheme"); + projectTheme = resources.getTheme("ProjectTheme"); + normalizeCompiledTheme(projectTheme); + } catch (Exception ex) { + projectTheme = null; + setStatus("Project CSS has errors: " + ex.getMessage()); + } + } + + /** + * Writes the companion Java source for the current document, regenerating everything outside + * the user-code markers from the .gui model and keeping only what the developer wrote between + * them. + * + *

The editor deliberately leaves the generated regions editable, because marking them read + * only refused every keystroke wherever the caret happened to land. That makes regenerating + * here the thing that keeps the promise the editor makes: edits to imports, buildUI or the + * class braces cannot survive to diverge from the document or stop the file compiling. + * + * @param path the companion source path + * @param userSource the text to carry the user-code region from -- the editor buffer when the + * code pane is saving, the file on disk when the designer is saving + * @return true when the file reached disk + */ + private boolean writeCompanionSource(String path, String userSource) { + try { + String generated = defaultCompanionSource(); + ProjectIO.write(path, userSource == null ? generated : mergeGeneratedSource(userSource, generated)); + return true; + } catch (IOException ex) { + ToastBar.showErrorMessage("Source save failed: " + ex.getMessage()); + return false; + } + } + + private void saveSource(String path, String source) { + if (writeCompanionSource(path, source)) { + // The file now matches what was written, not the raw buffer: the generated regions were + // regenerated on the way out, so Close must compare against that. + editorBufferOnDisk = editorBuffer; + setStatus("Saved companion Java source"); + } + } + + private void saveSourceAndModel(String path, String source) { + saveSource(path, source); + setStatus(ensureBindingModel(path) + ? "Saved form source and created its binding model" + : "Saved form source • existing model left unchanged"); + } + + /** + * Writes the binding model the generated companion refers to, when the form uses a binding + * strategy and the model is not already there. + * + * @param sourcePath the companion source path + * @return true when a model was created by this call + */ + private boolean ensureBindingModel(String sourcePath) { + if ("none".equals(value(document.root(), "bindingStrategy", "properties"))) return false; + String modelPath = sourcePath.substring(0, sourcePath.length() - 5) + "Model.java"; + if (ProjectIO.exists(modelPath)) return false; + try { + ProjectIO.write(modelPath, generatedModelSource()); + return true; + } catch (IOException ex) { + ToastBar.showErrorMessage("Model save failed: " + ex.getMessage()); + return false; + } + } + + private void normalizeCompiledTheme(Hashtable theme) { + if (theme == null) return; + List keys = new ArrayList<>(); + for (Object key : theme.keySet()) keys.add(key); + for (Object keyObject : keys) { + String key = String.valueOf(keyObject); + Object raw = theme.get(keyObject); + if (key.endsWith("." + Style.FONT) && raw instanceof String) { + String font = (String) raw; + if (font.length() > 1 && font.charAt(0) == '"' && font.charAt(font.length() - 1) == '"') { + font = font.substring(1, font.length() - 1); + } + try { + int style = font.indexOf("Bold") >= 0 ? Font.STYLE_BOLD : Font.STYLE_PLAIN; + if (font.indexOf("Italic") >= 0) style |= Font.STYLE_ITALIC; + theme.put(keyObject, Font.createTrueTypeFont(font, font).derive(Font.getDefaultFont().getHeight(), style)); + } catch (RuntimeException ex) { + theme.put(keyObject, Font.getDefaultFont()); + } + continue; + } + boolean padding = key.endsWith("." + Style.PADDING); + boolean margin = key.endsWith("." + Style.MARGIN); + if ((!padding && !margin) || !(raw instanceof String)) continue; + String[] values = ((String) raw).split(","); + if (values.length != 4) continue; + byte[] units = new byte[4]; + StringBuilder normalized = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + String item = values[i].trim(); + byte unit = Style.UNIT_TYPE_PIXELS; + if (item.endsWith("mm")) { unit = Style.UNIT_TYPE_DIPS; item = item.substring(0, item.length() - 2); } + else if (item.endsWith("px")) item = item.substring(0, item.length() - 2); + else if (item.endsWith("%")) { unit = Style.UNIT_TYPE_SCREEN_PERCENTAGE; item = item.substring(0, item.length() - 1); } + units[i] = unit; + if (i > 0) normalized.append(','); + normalized.append(item.length() == 0 ? "0" : item); + } + theme.put(keyObject, normalized.toString()); + String unitKey = key.substring(0, key.lastIndexOf('.') + 1) + (padding ? Style.PADDING_UNIT : Style.MARGIN_UNIT); + theme.put(unitKey, units); + } + } + + private String companionSourcePath() { + String relative = document.path().substring(ProjectIO.fsUrl(binding.guiDir()).length()); + if (relative.startsWith("/")) relative = relative.substring(1); + return binding.sourceDir() + "/" + relative.substring(0, relative.length() - 4) + ".java"; + } + + private String defaultCompanionSource() { + assignJavaNames(); + String form = relativeFormName(document.path()); + int dot = form.lastIndexOf('.'); + String packageName = dot < 0 ? "" : form.substring(0, dot); + String className = dot < 0 ? form : form.substring(dot + 1); + String modelName = className + "Model"; + String strategy = value(document.root(), "bindingStrategy", "properties"); + boolean bindingEnabled = !"none".equals(strategy); + boolean annotationBinding = "bindable".equals(strategy); + StringBuilder out = new StringBuilder(); + out.append("// \n"); + if (packageName.length() > 0) out.append("package ").append(packageName).append(";\n\n"); + out.append("import com.codename1.components.Accordion;\n") + .append("import com.codename1.components.SpanLabel;\n") + .append("import com.codename1.ui.*;\n") + .append("import com.codename1.ui.events.ActionEvent;\n") + .append("import com.codename1.ui.geom.Dimension;\n") + .append("import com.codename1.ui.layouts.*;\n") + .append("import com.codename1.ui.table.TableLayout;\n"); + if (bindingEnabled) out.append(annotationBinding + ? "import com.codename1.binding.Binding;\nimport com.codename1.binding.Binders;\n" + : "import com.codename1.properties.UiBinding;\n"); + // A .gui whose root is a Container is a reusable piece of UI, not a screen, and a Dialog is + // neither. Generating "extends Form" for all three produced a class the project could not + // use as the type it was designed as. Dialog takes the same (title, layout) constructor Form + // does; Container takes the layout alone. + String rootType = value(document.root(), "type", "Form"); + boolean containerRoot = "Container".equals(rootType); + String superClass = containerRoot ? "Container" : "Dialog".equals(rootType) ? "Dialog" : "Form"; + String superCall = containerRoot + ? "super(" + layoutSource(document.root()) + ");\n" + : "super(\"" + javaEscape(value(document.root(), "title", className)) + "\", " + + layoutSource(document.root()) + ");\n"; + out.append("\n// Generated live from ").append(relativeFormName(document.path())).append(".gui.\n") + .append("public class ").append(className).append(" extends ").append(superClass).append(" {\n"); + if (bindingEnabled) { + out.append(" private final ").append(modelName).append(" model;\n") + .append(annotationBinding ? " private Binding binding;\n" : " private UiBinding.Binding binding;\n"); + } + for (Element element : document.components()) { + if (element != document.root()) out.append(" private ").append(javaType(element)).append(" ") + .append(javaName(element)).append(";\n"); + } + if (bindingEnabled) { + out.append("\n public ").append(className).append("() { this(new ").append(modelName).append("()); }\n\n") + .append(" public ").append(className).append("(").append(modelName).append(" model) {\n") + .append(" ").append(superCall) + .append(" this.model = model;\n buildUI();\n") + .append(annotationBinding ? " binding = Binders.bind(model, this);\n }\n\n" + : " binding = new UiBinding().bind(model, this);\n }\n\n"); + } else { + out.append("\n public ").append(className).append("() {\n") + .append(" ").append(superCall).append(" buildUI();\n }\n\n"); + } + out.append(" private void buildUI() {\n"); + // The root's own inspector properties belong here. Emitting only the descendants meant a + // reusable Container root could have its UIID, RTL, visibility or scrolling set in the + // designer and shown in the preview while the runtime instance kept the defaults. + if (document.root().getAttribute("uiid") != null) { + out.append(" setUIID(\"").append(javaEscape(document.root().getAttribute("uiid"))).append("\");\n"); + } + appendGeneratedProperties(out, document.root(), "this", rootType, " "); + appendGeneratedChildren(out, document.root(), "this", " "); + for (Element command : document.commands()) { + String placement = value(command, "placement", "right"); + String method = "left".equals(placement) ? "addCommandToLeftBar" : "overflow".equals(placement) + ? "addCommandToOverflowMenu" : "side".equals(placement) ? "addCommandToSideMenu" : "addCommandToRightBar"; + out.append(" getToolbar().").append(method).append("(\"") + .append(javaEscape(value(command, "name", "Command"))).append("\", null, this::") + .append(javaIdentifier(value(command, "actionEvent", "onCommand"))).append(");\n"); + } + out.append(" }\n\n"); + if (bindingEnabled) out.append(" public ").append(modelName).append(" getModel() { return model; }\n\n"); + out.append("// \n") + .append("// \n"); + for (String handler : generatedHandlers()) { + out.append(handlerStub(handler)); + } + return out.append("// \n") + .append("// \n}\n// \n").toString(); + } + + private void appendGeneratedChildren(StringBuilder out, Element parent, String parentName, String indent) { + for (int i = 0; i < parent.getNumChildren(); i++) { + Object value = parent.getChildAt(i); + if (!(value instanceof Element) || !"component".equals(((Element) value).getTagName())) continue; + String name = javaName(((Element) value)); + String type = value(((Element) value), "type", "Container"); + out.append(indent).append(name).append(" = ").append(componentSource(((Element) value))).append(";\n") + .append(indent).append(name).append(".setName(\"").append(javaEscape(value(((Element) value), "name", name))).append("\");\n"); + if (((Element) value).getAttribute("uiid") != null) out.append(indent).append(name).append(".setUIID(\"") + .append(javaEscape(((Element) value).getAttribute("uiid"))).append("\");\n"); + appendHint(out, ((Element) value), name, type, indent); + appendGeneratedProperties(out, ((Element) value), name, type, indent); + String handler = ((Element) value).getAttribute("actionEvent"); + if (handler != null && handler.length() > 0 && firesActionEvents(type)) { + out.append(indent).append(name).append(".addActionListener(this::").append(javaIdentifier(handler)).append(");\n"); + } + if ("Tabs".equals(value(parent, "type", ""))) { + out.append(indent).append(parentName).append(".addTab(\"").append(javaEscape(value(((Element) value), "name", "Tab"))) + .append("\", ").append(name).append(");\n"); + } else if ("Accordion".equals(value(parent, "type", ""))) { + out.append(indent).append(parentName).append(".addContent(\"") + .append(javaEscape(value(((Element) value), "name", "Section"))).append("\", ").append(name).append(");\n"); + } else if ("BorderLayout".equals(value(parent, "layout", "BoxLayout"))) { + out.append(indent).append(parentName).append(".add(BorderLayout.") + .append(GuiDocument.effectiveBorderConstraint(parent, ((Element) value)).toUpperCase()).append(", ").append(name).append(");\n"); + } else if ("TableLayout".equals(value(parent, "layout", "BoxLayout"))) { + out.append(indent).append(parentName).append(".add(((TableLayout) ").append(parentName) + .append(".getLayout()).createConstraint(") + .append(GuiDocument.effectiveTableRow(parent, ((Element) value))) + .append(", ").append(GuiDocument.effectiveTableColumn(parent, ((Element) value))).append(")") + .append(".horizontalSpan(").append(value(((Element) value), "tableHorizontalSpan", "1")).append(")") + .append(".verticalSpan(").append(value(((Element) value), "tableVerticalSpan", "1")).append(")") + // The preview honours these percentages, so a form designed against them + // collapses to preferred-size columns at runtime when they are dropped here. + .append(percentageConstraint(((Element) value), "tableWidth", "widthPercentage")) + .append(percentageConstraint(((Element) value), "tableHeight", "heightPercentage")) + .append(", ").append(name).append(");\n"); + } else out.append(indent).append(parentName).append(".add(").append(name).append(");\n"); + if (GuiDocument.acceptsChildren(((Element) value))) appendGeneratedChildren(out, ((Element) value), name, indent); + appendGeneratedTabState(out, ((Element) value), name, type, indent); + } + if ("LayeredLayout".equals(value(parent, "layout", "BoxLayout"))) { + for (Element child : componentChildren(parent)) { + String name = javaName(child); + String[] refs = GuidedLayoutSupport.referenceNames(child); + out.append(indent).append("((LayeredLayout) ").append(parentName).append(".getLayout())") + .append(".setInsets(").append(name).append(", \"") + .append(javaEscape(value(child, "layeredInsets", "auto auto auto auto"))).append("\")") + .append(".setReferenceComponents(").append(name); + for (int side = 0; side < 4; side++) { + Element reference = namedSibling(parent, refs[side]); + out.append(", ").append(reference == null ? "null" : javaName(reference)); + } + out.append(")") + .append(".setReferencePositions(").append(name).append(", \"") + .append(javaEscape(value(child, "guidedReferencePositions", "0 0 0 0"))).append("\")") + .append(".setPercentInsetAnchorHorizontal(").append(name).append(", ") + .append(value(child, "guidedHorizontalAnchor", "0")).append("f)") + .append(".setPercentInsetAnchorVertical(").append(name).append(", ") + .append(value(child, "guidedVerticalAnchor", "0")).append("f);\n"); + } + } + } + + /** + * Emits the inspector properties that the preview applies through + * {@code ComponentPreviewFactory.applyAttributes}. Without these the designer showed a component + * as configured while the generated form fell back to every default, so the running app did not + * match the design. Only attributes the document actually carries are emitted, and only for the + * types that really own the setter, so the generated source stays readable and always compiles. + */ + private void appendGeneratedProperties(StringBuilder out, Element element, String name, String type, + String indent) { + appendBooleanSetter(out, element, name, indent, "enabled", "setEnabled"); + appendBooleanSetter(out, element, name, indent, "visible", "setVisible"); + appendBooleanSetter(out, element, name, indent, "rtl", "setRTL"); + if (isLabelType(type) || "SpanLabel".equals(type)) { + appendIntSetter(out, element, name, indent, "gap", "setGap"); + } + if (isLabelType(type) || "TextArea".equals(type) || "TextField".equals(type)) { + String alignment = element.getAttribute("alignment"); + if (alignment != null) out.append(indent).append(name).append(".setAlignment(Component.") + .append(alignmentConstant(alignment)).append(");\n"); + } + if (isLabelType(type)) { + appendBooleanSetter(out, element, name, indent, "tickerEnabled", "setTickerEnabled"); + } + if ("Button".equals(type)) appendBooleanSetter(out, element, name, indent, "toggle", "setToggle"); + if ("CheckBox".equals(type) || "RadioButton".equals(type)) { + appendBooleanSetter(out, element, name, indent, "selected", "setSelected"); + } + if ("TextField".equals(type) || "TextArea".equals(type)) { + appendIntSetter(out, element, name, indent, "columns", "setColumns"); + appendIntSetter(out, element, name, indent, "maxSize", "setMaxSize"); + appendBooleanSetter(out, element, name, indent, "editable", "setEditable"); + appendBooleanSetter(out, element, name, indent, "growByContent", "setGrowByContent"); + String constraint = element.getAttribute("constraint"); + if (constraint != null) out.append(indent).append(name).append(".setConstraint(TextArea.") + .append(constraintConstant(constraint)).append(");\n"); + } + if ("TextArea".equals(type)) appendIntSetter(out, element, name, indent, "rows", "setRows"); + if ("Slider".equals(type)) { + appendIntSetter(out, element, name, indent, "minValue", "setMinValue"); + appendIntSetter(out, element, name, indent, "maxValue", "setMaxValue"); + appendIntSetter(out, element, name, indent, "progress", "setProgress"); + appendBooleanSetter(out, element, name, indent, "editable", "setEditable"); + appendBooleanSetter(out, element, name, indent, "infinite", "setInfinite"); + } + if (GuiDocument.acceptsChildren(element)) { + appendBooleanSetter(out, element, name, indent, "scrollableX", "setScrollableX"); + appendBooleanSetter(out, element, name, indent, "scrollableY", "setScrollableY"); + } + } + + /** + * Tab placement and selection are emitted after the tabs themselves, because selecting an index + * on an empty Tabs throws. + */ + private void appendGeneratedTabState(StringBuilder out, Element element, String name, String type, + String indent) { + if (!"Tabs".equals(type)) return; + String placement = element.getAttribute("tabPlacement"); + if (placement != null) out.append(indent).append(name).append(".setTabPlacement(Component.") + .append(tabPlacementConstant(placement)).append(");\n"); + Integer selected = parseInteger(element.getAttribute("selectedIndex")); + int tabs = componentChildren(element).size(); + if (selected != null && selected.intValue() >= 0 && selected.intValue() < tabs) { + out.append(indent).append(name).append(".setSelectedIndex(").append(selected.intValue()).append(", false);\n"); + } + } + + private void appendBooleanSetter(StringBuilder out, Element element, String name, String indent, + String attribute, String setter) { + String raw = element.getAttribute(attribute); + if (raw == null) return; + out.append(indent).append(name).append('.').append(setter).append('(') + .append("true".equals(raw) ? "true" : "false").append(");\n"); + } + + private void appendIntSetter(StringBuilder out, Element element, String name, String indent, + String attribute, String setter) { + Integer parsed = parseInteger(element.getAttribute(attribute)); + if (parsed == null) return; + out.append(indent).append(name).append('.').append(setter).append('(').append(parsed.intValue()).append(");\n"); + } + + private String percentageConstraint(Element element, String attribute, String method) { + Integer parsed = parseInteger(element.getAttribute(attribute)); + return parsed == null || parsed.intValue() < 0 ? "" : "." + method + "(" + parsed.intValue() + ")"; + } + + private boolean isLabelType(String type) { + return "Label".equals(type) || "Button".equals(type) || "CheckBox".equals(type) + || "RadioButton".equals(type); + } + + private static String alignmentConstant(String value) { + if ("center".equalsIgnoreCase(value)) return "CENTER"; + if ("right".equalsIgnoreCase(value)) return "RIGHT"; + return "LEFT"; + } + + private static String tabPlacementConstant(String value) { + if ("bottom".equalsIgnoreCase(value)) return "BOTTOM"; + if ("left".equalsIgnoreCase(value)) return "LEFT"; + if ("right".equalsIgnoreCase(value)) return "RIGHT"; + return "TOP"; + } + + private static String constraintConstant(String value) { + if ("EMAILADDR".equals(value) || "PASSWORD".equals(value) || "NUMERIC".equals(value) + || "URL".equals(value)) { + return value; + } + return "ANY"; + } + + private String handlerStub(String handler) { + return " protected void " + handler + "(ActionEvent event) {\n" + + " // Add behavior here.\n }\n\n"; + } + + private String generatedModelSource() { + assignJavaNames(); + String form = relativeFormName(document.path()); + int dot = form.lastIndexOf('.'); + String packageName = dot < 0 ? "" : form.substring(0, dot); + String className = (dot < 0 ? form : form.substring(dot + 1)) + "Model"; + String strategy = value(document.root(), "bindingStrategy", "properties"); + List bindable = new ArrayList<>(); + for (Element element : document.components()) { + String type = value(element, "type", ""); + if ("TextField".equals(type) || "TextArea".equals(type) || "CheckBox".equals(type) || "RadioButton".equals(type)) bindable.add(element); + } + StringBuilder out = new StringBuilder(); + if (packageName.length() > 0) out.append("package ").append(packageName).append(";\n\n"); + if ("none".equals(strategy)) { + return out.append("// Data binding is disabled for this form.\n") + .append("public class ").append(className).append(" {\n}\n").toString(); + } + if ("bindable".equals(strategy)) { + out.append("import com.codename1.annotations.Bind;\n") + .append("import com.codename1.annotations.Bindable;\n") + .append("import com.codename1.binding.BindAttr;\n\n") + .append("@Bindable\npublic class ").append(className).append(" {\n"); + for (Element element : bindable) { + String type = value(element, "type", ""); + boolean selected = "CheckBox".equals(type) || "RadioButton".equals(type); + String name = javaName(element); + String cap = Character.toUpperCase(name.charAt(0)) + name.substring(1); + out.append(" @Bind(name = \"").append(javaEscape(value(element, "name", name))) + .append("\", attr = BindAttr.").append(selected ? "SELECTED" : "TEXT").append(")\n") + .append(" private ").append(selected ? "boolean" : "String").append(" ").append(name).append(" = ") + .append(selected ? String.valueOf("true".equals(value(element, "selected", "false"))) + : "\"" + javaEscape(value(element, "text", "")) + "\"").append(";\n\n") + .append(" public ").append(selected ? "boolean is" : "String get").append(cap).append("() { return ") + .append(name).append("; }\n") + .append(" public void set").append(cap).append("(").append(selected ? "boolean" : "String") + .append(" value) { this.").append(name).append(" = value; }\n\n"); + } + return out.append("}\n").toString(); + } + out.append("import com.codename1.properties.*;\n\npublic class ").append(className) + .append(" implements PropertyBusinessObject {\n"); + for (Element element : bindable) { + String type = value(element, "type", ""); + boolean selected = "CheckBox".equals(type) || "RadioButton".equals(type); + out.append(" public final Property<").append(selected ? "Boolean" : "String").append(", ").append(className).append("> ") + .append(javaName(element)).append(" = new Property<>(\"").append(javaEscape(value(element, "name", javaName(element)))) + .append("\", ").append(selected ? String.valueOf("true".equals(value(element, "selected", "false"))) + : "\"" + javaEscape(value(element, "text", "")) + "\"").append(");\n"); + } + out.append(" private final PropertyIndex index = new PropertyIndex(this, \"").append(className).append("\""); + for (Element element : bindable) out.append(", ").append(javaName(element)); + return out.append(");\n @Override public PropertyIndex getPropertyIndex() { return index; }\n}\n").toString(); + } + + /** + * True for the root types that carry a title and a toolbar. {@code cn1:create-gui-form + * -DguiType=Dialog} is explicitly supported and the generator already keeps Dialog as the + * superclass, so a Form-only test left those forms unable to edit the very attributes the + * generated source consumes. + * + * @param type the {@code type} attribute of the element + * @return true when the element behaves like a form + */ + /** + * True for the component types that expose {@code addActionListener}. The Events tab offers a + * handler for all of them and a stub is generated either way, so leaving text inputs and + * sliders out here produced a form that compiled while the handler was never invoked. + * + * @param type the {@code type} attribute of the element + * @return true when a listener can be registered on this type + */ + private static boolean firesActionEvents(String type) { + return "Button".equals(type) || "CheckBox".equals(type) || "RadioButton".equals(type) + || "TextField".equals(type) || "TextArea".equals(type) || "Slider".equals(type); + } + + private static boolean isFormLike(String type) { + return "Form".equals(type) || "Dialog".equals(type); + } + + private String javaType(Element element) { + String type = value(element, "type", "Container"); + return isFormLike(type) ? "Container" : type; + } + + /** + * Appends the hint for a text component. The inspector offers Hint for TextArea and applies it + * live, so omitting it from the generated source made it vanish on the next canvas rebuild and + * never reach the compiled form. + * + * @param out the source being built + * @param element the component element + * @param name the generated field name + * @param type the component type + * @param indent the current indent + */ + private void appendHint(StringBuilder out, Element element, String name, String type, String indent) { + if (!"TextArea".equals(type)) return; + String hint = element.getAttribute("hint"); + if (hint == null || hint.length() == 0) return; + out.append(indent).append(name).append(".setHint(\"").append(javaEscape(hint)).append("\");\n"); + } + + private String componentSource(Element element) { + String type = value(element, "type", "Container"); + String text = "\"" + javaEscape(value(element, "text", "")) + "\""; + String source; + if ("Container".equals(type)) source = "new Container(" + layoutSource(element) + ")"; + else if ("Tabs".equals(type)) source = "new Tabs()"; + // Accordion is a child-accepting type the document model already supports, and it lives in + // com.codename1.components with no String constructor, so the generic "new Type(text)" + // fallback produced source that does not compile. + else if ("Accordion".equals(type)) source = "new Accordion()"; + else if ("Slider".equals(type)) source = "new Slider()"; + else if ("TextField".equals(type)) source = "new TextField(" + text + ", \"" + javaEscape(value(element, "hint", "")) + "\")"; + else if ("TextArea".equals(type)) source = "new TextArea(" + text + ")"; + else source = "new " + type + "(" + text + ")"; + String fixedWidth = element.getAttribute("guidedPreferredWidth"); + String fixedHeight = element.getAttribute("guidedPreferredHeight"); + if (fixedWidth == null && fixedHeight == null) return source; + return source + " { @Override protected Dimension calcPreferredSize() { " + + "Dimension size = super.calcPreferredSize(); " + + (fixedWidth == null ? "" : "size.setWidth(" + fixedWidth + "); ") + + (fixedHeight == null ? "" : "size.setHeight(" + fixedHeight + "); ") + + "return size; } }"; + } + + private String layoutSource(Element element) { + String layout = value(element, "layout", "BoxLayout"); + if ("BorderLayout".equals(layout)) return "new BorderLayout()"; + if ("FlowLayout".equals(layout)) return "new FlowLayout()"; + if ("GridLayout".equals(layout)) return "new GridLayout(" + value(element, "gridLayoutRows", "1") + ", " + value(element, "gridLayoutColumns", "2") + ")"; + if ("TableLayout".equals(layout)) return "new TableLayout(" + value(element, "tableLayoutRows", "2") + ", " + value(element, "tableLayoutColumns", "2") + ")"; + if ("LayeredLayout".equals(layout)) return "new LayeredLayout()"; + return "X".equals(value(element, "boxLayoutAxis", "Y")) ? "BoxLayout.x()" : "BoxLayout.y()"; + } + + private List generatedHandlers() { + List handlers = new ArrayList<>(); + for (Element element : document.components()) { + String handler = element.getAttribute("actionEvent"); + if (handler != null && handler.length() > 0 && !handlers.contains(javaIdentifier(handler))) handlers.add(javaIdentifier(handler)); + } + for (Element command : document.commands()) { + String handler = javaIdentifier(value(command, "actionEvent", "onCommand")); + if (!handlers.contains(handler)) handlers.add(handler); + } + return handlers; + } + + /** + * The Java name of a component, unique across the document. A .gui may legitimately name two + * components "foo-bar" and "foo_bar", or name one "class"; both produce companion source that + * does not compile unless the names are disambiguated here. + */ + private String javaName(Element element) { + String assigned = javaNames.get(element); + return assigned != null ? assigned : javaIdentifier(value(element, "name", value(element, "type", "component"))); + } + + /** Recomputed for every generation because a rename changes every name after it. */ + private void assignJavaNames() { + javaNames = new LinkedHashMap<>(); + Set used = new LinkedHashSet<>(); + // the names the generated class itself declares + used.add("model"); + used.add("binding"); + used.add("buildUI"); + // The PropertyBusinessObject model declares its own PropertyIndex called index, so a + // bindable control named "index" produced a model with two fields of that name. + used.add("index"); + for (Element element : document.components()) { + if (element == document.root()) continue; + String base = javaIdentifier(value(element, "name", value(element, "type", "component"))); + String candidate = base; + int suffix = 2; + while (!used.add(candidate)) { + candidate = base + suffix; + suffix++; + } + javaNames.put(element, candidate); + } + } + + private String javaIdentifier(String value) { + String cleaned = value == null ? "component" : value.replaceAll("[^A-Za-z0-9_$]", "_"); + if (cleaned.length() == 0) return "component"; + char first = cleaned.charAt(0); + boolean validStart = first == '_' || first == '$' || first >= 'A' && first <= 'Z' || first >= 'a' && first <= 'z'; + String identifier = validStart ? cleaned : "_" + cleaned; + // A component may legitimately be called "class" or "new"; the field or method generated + // from it may not. + return isJavaReserved(identifier) ? identifier + "_" : identifier; + } + + private static final String JAVA_RESERVED = "|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|null|" + + "package|private|protected|public|return|short|static|strictfp|super|switch|" + + "synchronized|this|throw|throws|transient|true|try|void|volatile|while|_|var|record|" + + "sealed|permits|yield|"; + + private static boolean isJavaReserved(String identifier) { + return JAVA_RESERVED.indexOf("|" + identifier + "|") >= 0; + } + private String javaEscape(String value) { + return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", ""); + } + + private String ensureHandler(String source, String handler) { + if (source.indexOf(handler + "(") >= 0) return source; + int close = source.indexOf("// "); + if (close < 0) close = source.lastIndexOf('}'); + String method = "\n private void " + handler + "(ActionEvent event) {\n" + + " // Add event behavior here.\n" + + " }\n"; + return close < 0 ? source + method : source.substring(0, close) + method + source.substring(close); + } + + private String mergeGeneratedSource(String existing, String generated) { + String startMarker = "// "; + String endMarker = "// "; + int oldStart = existing.indexOf(startMarker); + int oldEnd = existing.indexOf(endMarker); + if (oldStart < 0 || oldEnd < oldStart) { + if (existing.indexOf("// Generated live from ") >= 0) return generated; + String migrated = migrateLegacySource(existing, generated); + return migrated != null ? migrated : existing; + } + int newStart = generated.indexOf(startMarker); + int newEnd = generated.indexOf(endMarker); + if (newStart < 0 || newEnd < newStart) return generated; + String userCode = existing.substring(oldStart + startMarker.length(), oldEnd); + return generated.substring(0, newStart + startMarker.length()) + userCode + generated.substring(newEnd); + } + + static final String LEGACY_GENERATED_START = "//-- DON'T EDIT BELOW THIS LINE!!!"; + static final String LEGACY_GENERATED_END = "//-- DON'T EDIT ABOVE THIS LINE!!!"; + + /** + * Rewrites a companion source scaffolded by an older {@code cn1:create-gui-form} into this + * editor's format. Those files carry {@code //-- DON'T EDIT} markers and an empty + * {@code initGuiBuilderComponents}, which this editor does not write to, so without this a form + * designed in a project created by the old scaffolder saved its .gui and produced an empty + * screen at runtime. Anything the developer added to the class is carried into the user-code + * region; the old constructors and the old generated block are dropped because the new + * generated region replaces both. + * + * @return the migrated source, or null when this is not a legacy scaffolded file + */ + String migrateLegacySource(String existing, String generated) { + int legacyStart = existing.indexOf(LEGACY_GENERATED_START); + int legacyEnd = existing.indexOf(LEGACY_GENERATED_END); + if (legacyStart < 0 || legacyEnd < legacyStart) return null; + String carried = legacyUserMembers(existing, legacyStart, legacyEnd); + String marker = "// "; + if (carried.length() == 0) return carriedImports(existing, generated); + String merged = carriedImports(existing, dropStubsAlreadyWritten(generated, carried)); + int insert = merged.indexOf(marker); + if (insert < 0) return merged; + insert += marker.length(); + return merged.substring(0, insert) + "\n" + carried + "\n" + merged.substring(insert); + } + + /** + * Adds the legacy file's own imports to the generated header. Migration carries the class body + * across but the header is regenerated, so a user method that depended on a custom import + * compiled before the migration and not after it -- an existing project would stop building on + * the first save in this editor. + * + * @param existing the legacy companion source + * @param generated the freshly generated source to add the imports to + * @return the generated source with any imports it was missing + */ + private String carriedImports(String existing, String generated) { + int classAt = classDeclaration(existing); + String header = classAt < 0 ? existing : existing.substring(0, classAt); + StringBuilder missing = new StringBuilder(); + for (String line : header.split("\n")) { + String statement = line.trim(); + if (!statement.startsWith("import ") || !statement.endsWith(";")) continue; + if (generated.indexOf(statement) >= 0) continue; + missing.append(statement).append('\n'); + } + if (missing.length() == 0) return generated; + int anchor = generated.lastIndexOf("\nimport "); + if (anchor < 0) return generated; + int endOfImports = generated.indexOf('\n', anchor + 1); + if (endOfImports < 0) return generated; + return generated.substring(0, endOfImports + 1) + missing + generated.substring(endOfImports + 1); + } + + /** + * Removes the empty handler stubs for events the migrated code already implements. The old + * scaffold kept the developer's handlers outside its generated block, so carrying them over + * without this produced a class declaring the same method twice. + */ + private String dropStubsAlreadyWritten(String generated, String carried) { + String out = generated; + for (String handler : generatedHandlers()) { + if (carried.indexOf(handler + "(") < 0) continue; + out = out.replace(handlerStub(handler), ""); + } + return out; + } + + private String legacyUserMembers(String existing, int legacyStart, int legacyEnd) { + int bodyStart = existing.indexOf('{', classDeclaration(existing)); + int bodyEnd = existing.lastIndexOf('}'); + if (bodyStart < 0 || bodyEnd <= bodyStart) return ""; + String body = existing.substring(bodyStart + 1, bodyEnd); + int offset = bodyStart + 1; + if (legacyStart > offset && legacyEnd > legacyStart) { + int endOfLine = existing.indexOf('\n', legacyEnd); + int cut = (endOfLine < 0 ? existing.length() : endOfLine + 1) - offset; + body = body.substring(0, legacyStart - offset) + body.substring(Math.min(cut, body.length())); + } + return removeConstructors(body, className(existing)).trim(); + } + + private int classDeclaration(String source) { + int index = source.indexOf("class "); + return index < 0 ? 0 : index; + } + + private String className(String source) { + int index = classDeclaration(source); + if (index == 0 && !source.startsWith("class ")) return ""; + int start = index + "class ".length(); + int end = start; + while (end < source.length() && isIdentifierChar(source.charAt(end))) end++; + return source.substring(start, end); + } + + private static boolean isIdentifierChar(char c) { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '$'; + } + + /** + * Drops every constructor of the scaffolded class. The generated region declares its own, and + * the scaffolded ones call an {@code initGuiBuilderComponents} that no longer exists. + */ + private String removeConstructors(String body, String className) { + if (className.length() == 0) return body; + String out = body; + int from = 0; + while (true) { + int index = out.indexOf(className + "(", from); + if (index < 0) return out; + char before = index == 0 ? ' ' : out.charAt(index - 1); + int close = matchingBrace(out, out.indexOf(")", index)); + boolean instantiation = index >= 4 && "new ".equals(out.substring(index - 4, index)); + if (isIdentifierChar(before) || before == '.' || instantiation || close < 0) { + from = index + className.length(); + continue; + } + int lineStart = out.lastIndexOf("\n", index); + int cut = lineStart < 0 ? 0 : lineStart; + out = out.substring(0, cut) + out.substring(close + 1); + from = cut; + } + } + + /** + * Finds the closing brace of the block that starts after {@code afterIndex}, ignoring braces + * inside string and character literals so a constructor that builds text is not truncated. + */ + private int matchingBrace(String source, int afterIndex) { + if (afterIndex < 0) return -1; + int depth = 0; + boolean started = false; + char quote = 0; + for (int i = afterIndex; i < source.length(); i++) { + char c = source.charAt(i); + if (quote != 0) { + if (c == '\\') i++; + else if (c == quote) quote = 0; + continue; + } + if (c == '"' || c == '\'') { quote = c; continue; } + if (c == '{') { depth++; started = true; } + else if (c == '}') { + depth--; + if (started && depth == 0) return i; + if (depth < 0) return -1; + } else if (c == ';' && !started) { + return -1; + } + } + return -1; + } + + private String resolveInitialForm() { + String requested = binding.initialForm(); + if (requested == null || requested.length() == 0) return null; + for (String file : guiFiles) { + if (file.endsWith(requested.replace('.', '/') + ".gui") || file.endsWith(requested + ".gui")) return file; + } + return null; + } + + private String relativeFormName(String path) { + String base = binding == null || binding.guiDir() == null ? "" : ProjectIO.fsUrl(binding.guiDir()); + String relative = path.startsWith(base) ? path.substring(base.length()) : path; + if (relative.startsWith("/")) relative = relative.substring(1); + return relative.endsWith(".gui") ? relative.substring(0, relative.length() - 4).replace('/', '.') : relative; + } + + private Button iconButton(char icon, String tooltip, Runnable action) { + Button button = new Button(material(icon, "BuilderCanvasIcon")); + button.setUIID("BuilderCanvasButton"); + button.setName(tooltip); + button.setAccessibilityText(tooltip); + button.getSemantics().setIdentifier("guibuilder.canvasMode." + semanticKey(tooltip)).setLabel(tooltip); + button.addActionListener(e -> action.run()); + return button; + } + + private Label sectionTitle(String title) { + Label label = new Label(title, "BuilderSectionTitle"); + label.getSemantics().setRole(AccessibilityRole.HEADING).setHeadingLevel(2).setLabel(title); + return label; + } + private boolean hasText(String type) { + return "Label".equals(type) || "SpanLabel".equals(type) || "Button".equals(type) + || "CheckBox".equals(type) || "RadioButton".equals(type) + || "TextField".equals(type) || "TextArea".equals(type); + } + /** + * The fallback applies to an attribute that is absent, not to one the user deliberately + * emptied: clearing a label's text must leave it empty rather than bringing the sample text + * back. Structural attributes are removed rather than blanked, so they are unaffected. + */ + private static String value(Element element, String attribute, String fallback) { + String result = element == null ? null : element.getAttribute(attribute); + return result == null ? fallback : result; + } + private FontImage material(char icon, String uiid) { return FontImage.createMaterial(icon, UIManager.getInstance().getComponentStyle(uiid)); } + private void setStatus(String message) { + String previous = status == null ? null : status.getText(); + if (status != null) status.setText(message); + if (previous == null || !previous.equals(message)) recordAction("status", "message", message); + } + + private static String snapReferenceName(SnapResult snap) { + return snap == null || snap.reference == null ? null + : value(snap.reference, "name", value(snap.reference, "type", "component")); + } + + private void recordAction(String kind, Object... values) { + if (mcpController == null) return; + Map details = new LinkedHashMap(); + for (int i = 0; values != null && i + 1 < values.length; i += 2) { + if (values[i] != null && values[i + 1] != null) details.put(String.valueOf(values[i]), values[i + 1]); + } + if (document != null) { + details.put("form", relativeFormName(document.path())); + Element selected = document.selected(); + if (selected != null) details.put("selected", value(selected, "name", value(selected, "type", "component"))); + } + mcpController.record(kind, details); + } + + Map mcpState(long latestSequence) { + Map out = new LinkedHashMap(); + out.put("latestSequence", Long.valueOf(latestSequence)); + out.put("canvasMode", canvasMode); + out.put("darkMode", Boolean.valueOf(darkMode)); + out.put("status", status == null ? null : status.getText()); + List forms = new ArrayList(); + for (String path : guiFiles) forms.add(relativeFormName(path)); + out.put("forms", forms); + if (canvasHost != null) out.put("canvasBounds", componentBounds(canvasHost)); + out.put("previewUIManager", String.valueOf(System.identityHashCode(previewUIManager))); + out.put("globalUIManager", String.valueOf(System.identityHashCode(UIManager.getInstance()))); + out.put("projectThemeLoaded", Boolean.valueOf(projectTheme != null)); + out.put("themeApplyCount", Integer.valueOf(themeApplyCount)); + // What each manager resolves for a plain Label, so the stylesheet on disk, the compiled + // theme and what the canvas resolves can be compared from outside the process. + out.put("previewLabelFg", String.format("%06x", + Integer.valueOf(previewUIManager.getComponentStyle("Label").getFgColor()))); + out.put("globalLabelFg", String.format("%06x", + Integer.valueOf(UIManager.getInstance().getComponentStyle("Label").getFgColor()))); + if (projectTheme != null) { + out.put("projectThemeLabelFg", String.valueOf(projectTheme.get("Label.fgColor"))); + out.put("projectThemeKeys", Integer.valueOf(projectTheme.size())); + } + if (binding != null) out.put("cssFile", binding.cssFile()); + if (document == null) return out; + normalizeSelection(); + out.put("activeForm", relativeFormName(document.path())); + out.put("path", document.path()); + out.put("modified", Boolean.valueOf(document.isModified())); + out.put("canUndo", Boolean.valueOf(document.canUndo())); + out.put("canRedo", Boolean.valueOf(document.canRedo())); + Element selected = document.selected(); + out.put("selected", selected == null ? null : value(selected, "name", value(selected, "type", "component"))); + List selection = new ArrayList(); + for (Element element : selectedElements) selection.add(value(element, "name", value(element, "type", "component"))); + out.put("selectedComponents", selection); + int[] selectionPaintBounds = dragGuideOverlay == null ? null : dragGuideOverlay.selectionPaintBounds(); + if (selectionPaintBounds != null) out.put("selectionPaintBounds", rect(selectionPaintBounds[0], + selectionPaintBounds[1], selectionPaintBounds[2], selectionPaintBounds[3])); + if (dragGuideOverlay != null) { + List selectionBounds = new ArrayList(); + for (int[] bounds : dragGuideOverlay.selectionPaintBoundsList()) { + selectionBounds.add(rect(bounds[0], bounds[1], bounds[2], bounds[3])); + } + out.put("selectionPaintBoundsList", selectionBounds); + } + List components = new ArrayList(); + for (Element element : document.components()) { + Map item = new LinkedHashMap(); + String name = value(element, "name", value(element, "type", "component")); + item.put("name", name); + item.put("type", value(element, "type", "Component")); + Element parent = document.parentOf(element); + item.put("parent", parent == null ? null : value(parent, "name", value(parent, "type", "component"))); + if (GuiDocument.acceptsChildren(element)) item.put("layout", value(element, "layout", "BoxLayout")); + Map attributes = new LinkedHashMap(); + Hashtable raw = element.getAttributes(); + if (raw != null) { + Enumeration keys = raw.keys(); + while (keys.hasMoreElements()) { + Object key = keys.nextElement(); + attributes.put(String.valueOf(key), raw.get(key)); + } + } + item.put("attributes", attributes); + Component preview = componentForElement(canvasHost, element); + if (preview != null) { + // The resolved style, not the stylesheet's text. This is what the canvas is actually + // drawing with, so it is the only way to tell from outside whether a CSS edit + // reached the preview or stopped somewhere on the way. + Map style = new LinkedHashMap(); + style.put("uiid", preview.getUIID()); + style.put("fgColor", String.format("%06x", Integer.valueOf(preview.getUnselectedStyle().getFgColor()))); + style.put("bgColor", String.format("%06x", Integer.valueOf(preview.getUnselectedStyle().getBgColor()))); + style.put("bgTransparency", Integer.valueOf(preview.getUnselectedStyle().getBgTransparency() & 0xff)); + style.put("uiManager", String.valueOf(System.identityHashCode(preview.getUIManager()))); + item.put("style", style); + item.put("bounds", componentBounds(preview)); + item.put("visible", Boolean.valueOf(preview.isVisible() && preview.getWidth() > 0 && preview.getHeight() > 0)); + item.put("accessibilityIdentifier", preview.getSemantics().getIdentifier()); + } + components.add(item); + } + out.put("components", components); + if (activeDropPlan != null) { + Map guide = new LinkedHashMap(); + guide.put("layout", activeDropPlan.layout); + guide.put("valid", Boolean.valueOf(activeDropPlan.valid)); + guide.put("constraint", activeDropPlan.constraint); + guide.put("target", value(activeDropPlan.target, "name", value(activeDropPlan.target, "type", "component"))); + guide.put("message", activeDropPlan.message); + guide.put("snapDescription", activeDropPlan.snapDescription); + guide.put("snapBounds", rect(activeDropPlan.snapX, activeDropPlan.snapY, + activeDropPlan.snapW, activeDropPlan.snapH)); + out.put("dropGuide", guide); + } + return out; + } + + boolean mcpSelectComponent(String componentName) { + return mcpSelectComponent(componentName, false); + } + + boolean mcpSelectComponent(String componentName, boolean additive) { + if (document == null || componentName == null) return false; + Element element = findElementNamed(document, componentName); + if (element == null) return false; + selectElement(element, additive, "mcp"); + setStatus("Selected " + componentName + " through MCP"); + return true; + } + + String mcpOpenForm(String formName) { + if (formName == null || formName.length() == 0) return "form is required"; + if (document != null && document.isModified()) return "Active form has unsaved changes"; + for (String path : guiFiles) { + String relative = relativeFormName(path); + String simple = relative.substring(relative.lastIndexOf('.') + 1); + if (formName.equals(relative) || formName.equals(simple) || formName.equals(path)) { + if (!openForm(path)) return "Could not open " + relative; + recordAction("form_opened", "form", relative, "source", "mcp"); + return null; + } + } + return "No form named " + formName; + } + + String mcpDragComponent(String componentName, String targetName, String placement, + Integer requestedX, Integer requestedY) { + if (document == null || canvasHost == null) return "No active GUI document"; + Element element = findElementNamed(document, componentName); + if (element == null || element == document.root()) return "No draggable component named " + componentName; + Component source = componentForElement(canvasHost, element); + if (source == null || source.getWidth() < 1 || source.getHeight() < 1) return componentName + " is not visible"; + if ((requestedX == null) != (requestedY == null)) return "x and y must be supplied together"; + int startX = source.getAbsoluteX() + source.getWidth() / 2; + int startY = source.getAbsoluteY() + source.getHeight() / 2; + int releaseX; + int releaseY; + if (requestedX != null) { + releaseX = requestedX.intValue(); + releaseY = requestedY.intValue(); + } else { + Element target = targetName == null || targetName.length() == 0 + ? document.root() : findElementNamed(document, targetName); + if (target == null) return "No target component named " + targetName; + Component targetPreview = componentForElement(canvasHost, target); + if (targetPreview == null) return "Target " + targetName + " is not visible"; + String where = placement == null || placement.length() == 0 ? "center" : placement; + int gap = standardGap(); + // Aim the POINTER, not the dragged component's top-left corner. A drop is resolved from + // the pointer position, so deriving the release point from the component's own box sent + // the pointer outside the target whenever the component was larger than it -- dropping + // a full width button "into" a narrow column landed it in the next column instead. + int centreX = targetPreview.getAbsoluteX() + targetPreview.getWidth() / 2; + int centreY = targetPreview.getAbsoluteY() + targetPreview.getHeight() / 2; + releaseX = centreX; + releaseY = centreY; + if ("above".equals(where)) releaseY = targetPreview.getAbsoluteY() - gap; + else if ("below".equals(where)) releaseY = targetPreview.getAbsoluteY() + targetPreview.getHeight() + gap; + else if ("leftOf".equals(where)) releaseX = targetPreview.getAbsoluteX() - gap; + else if ("rightOf".equals(where)) releaseX = targetPreview.getAbsoluteX() + targetPreview.getWidth() + gap; + else if ("before".equals(where) || "after".equals(where)) { + // Stay inside the target: before/after is an insertion next to it in its own + // parent, and dropAfter() decides the side from which half of it the pointer is in. + Element parent = document.parentOf(target); + boolean horizontal = parent != null && ("X".equals(value(parent, "boxLayoutAxis", "Y")) + || "FlowLayout".equals(value(parent, "layout", "BoxLayout")) + || "GridLayout".equals(value(parent, "layout", "BoxLayout"))); + if (horizontal) { + releaseX = "after".equals(where) ? targetPreview.getAbsoluteX() + targetPreview.getWidth() - 1 + : targetPreview.getAbsoluteX() + 1; + } else { + releaseY = "after".equals(where) ? targetPreview.getAbsoluteY() + targetPreview.getHeight() - 1 + : targetPreview.getAbsoluteY() + 1; + } + } else if (!"center".equals(where)) { + return "Unknown placement " + where; + } + } + String before = document.toXml(); + handleDesignerPointerPressed(startX, startY); + updateDesignerDrag(releaseX, releaseY); + finishDesignerDrag(releaseX, releaseY); + if (before.equals(document.toXml())) return "Drag completed without changing the document: " + + (status == null ? "no valid drop" : status.getText()); + recordAction("mcp_drag_completed", "component", componentName, + "x", Integer.valueOf(releaseX), "y", Integer.valueOf(releaseY)); + return null; + } + + String mcpCommand(String command) { + if (command == null) return "command is required"; + // A client that is told the save succeeded will happily go on to close the editor. + if ("save".equals(command)) { + if (!save()) return "Save failed; the form is still only in the editor"; + } + else if ("undo".equals(command)) undo(); + else if ("redo".equals(command)) redo(); + else if ("refresh".equals(command)) refreshProject(); + else if ("toggleDarkMode".equals(command)) toggleDarkMode(); + else if ("phonePortrait".equals(command) || "phoneLandscape".equals(command) + || "tabletPortrait".equals(command) || "desktop".equals(command)) setCanvasMode(command); + else return "Unknown command " + command; + recordAction("mcp_command", "command", command); + return null; + } + + private static List componentBounds(Component component) { + return rect(component.getAbsoluteX(), component.getAbsoluteY(), component.getWidth(), component.getHeight()); + } + + private static List rect(int x, int y, int width, int height) { + List bounds = new ArrayList(); + bounds.add(Integer.valueOf(x)); + bounds.add(Integer.valueOf(y)); + bounds.add(Integer.valueOf(width)); + bounds.add(Integer.valueOf(height)); + return bounds; + } + + private static String semanticKey(String value) { + StringBuilder out = new StringBuilder(); + for (int i = 0; value != null && i < value.length(); i++) { + char ch = Character.toLowerCase(value.charAt(i)); + if (ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9') out.append(ch); + else if (out.length() > 0 && out.charAt(out.length() - 1) != '.') out.append('.'); + } + while (out.length() > 0 && out.charAt(out.length() - 1) == '.') out.deleteCharAt(out.length() - 1); + return out.toString(); + } + + public static void saveActiveDocument() { if (active != null) active.save(); } + public static void refreshActiveProject() { if (active != null) active.refreshProject(); } + public static void openActiveCss() { if (active != null) active.openCss(); } + public static void openActiveSource() { if (active != null) active.openCompanionSource(); } + public static void openActiveModel() { if (active != null) active.openBindingModel(); } + static void openActiveCssForTest() { if (active != null) active.openCss(); } + static CodeEditor activeCodeEditorForTest() { return active == null ? null : active.activeCodeEditor; } + static int activePreviewForegroundForTest(String componentName) { + if (active == null || active.document == null || !(active.previewRoot instanceof Container)) return -1; + for (Element element : active.document.components()) { + if ((componentName == null && "Button".equals(element.getAttribute("type"))) + || (componentName != null && componentName.equals(element.getAttribute("name")))) { + Component component = active.componentForElement((Container) active.previewRoot, element); + return component == null ? -1 : component.getStyle().getFgColor(); + } + } + return -1; + } + static int[] activePreviewBoundsForTest(String componentName) { + if (active == null || active.document == null || !(active.previewRoot instanceof Container)) return null; + for (Element element : active.document.components()) { + if (componentName.equals(element.getAttribute("name"))) { + Component component = active.componentForElement((Container) active.previewRoot, element); + return component == null ? null : new int[]{component.getAbsoluteX(), component.getAbsoluteY(), + component.getWidth(), component.getHeight()}; + } + } + return null; + } + static String activeDocumentAttributeForTest(String componentName, String attribute) { + if (active == null || active.document == null) return null; + for (Element element : active.document.components()) { + if (componentName.equals(element.getAttribute("name"))) return element.getAttribute(attribute); + } + return null; + } + static int activePreviewBaselineForTest(String componentName) { + int[] bounds = activePreviewBoundsForTest(componentName); + if (bounds == null || active == null) return -1; + for (Element element : active.document.components()) { + if (componentName.equals(element.getAttribute("name"))) { + Component component = active.componentForElement((Container) active.previewRoot, element); + int baseline = component == null ? -1 : component.getBaseline(component.getWidth(), component.getHeight()); + return baseline < 0 ? -1 : component.getAbsoluteY() + baseline; + } + } + return -1; + } + static String activeDesignerStateForTest() { + return active == null ? "inactive" : "armed=" + active.designerDragArmed + + ", active=" + active.designerDragActive + + ", resizeArmed=" + active.guidedResizeArmed + + ", resizeActive=" + active.guidedResizeActive + + ", press=" + active.designerPressX + "," + active.designerPressY + + ", status=" + (active.status == null ? "" : active.status.getText()); + } + static String activeSelectedNameForTest() { + return active == null || active.document == null || active.document.selected() == null ? null + : active.document.selected().getAttribute("name"); + } + static String activeCanvasModeForTest() { return active == null ? null : active.canvasMode; } + static int[] activeNamedUiBoundsForTest(String componentName) { + if (active == null || active.workspace == null) return null; + Component component = active.findNamedUiComponent(active.workspace, componentName); + return component == null ? null : new int[]{component.getAbsoluteX(), component.getAbsoluteY(), + component.getWidth(), component.getHeight()}; + } + private Component findNamedUiComponent(Component component, String componentName) { + if (componentName.equals(component.getName())) return component; + if (component instanceof Container) { + for (int i = 0; i < ((Container) component).getComponentCount(); i++) { + Component found = findNamedUiComponent(((Container) component).getComponentAt(i), componentName); + if (found != null) return found; + } + } + return null; + } + static void activeDesktopPointerDragged(int x, int y) { + if (active == null || !active.designerDragArmed && !active.guidedResizeArmed) return; + if (active.guidedResizeArmed) active.updateGuidedResize(x, y); else active.updateDesignerDrag(x, y); + } + static void activeDesktopPointerReleased(int x, int y) { + if (active == null || !active.designerDragArmed && !active.guidedResizeArmed) return; + if (active.guidedResizeArmed) active.finishGuidedResize(x, y); else active.finishDesignerDrag(x, y); + } + public static void cutActiveSelection() { if (active != null) active.cut(); } + public static void copyActiveSelection() { if (active != null) active.copy(); } + public static void pasteActiveSelection() { if (active != null) active.paste(); } + public static void deleteActiveSelection() { if (active != null) active.deleteSelection(); } + public static void undoActiveEdit() { if (active != null) active.undo(); } + public static void redoActiveEdit() { if (active != null) active.redo(); } + public static void toggleActiveDarkMode() { if (active != null) active.toggleDarkMode(); } + public static boolean isActiveDarkMode() { return active != null && active.darkMode; } + public static String[] activeFormNames() { + if (active == null) return new String[0]; + String[] names = new String[active.guiFiles.size()]; + for (int i = 0; i < names.length; i++) names[i] = active.relativeFormName(active.guiFiles.get(i)); + return names; + } + public static void openActiveForm(int index) { + if (active != null && index >= 0 && index < active.guiFiles.size()) active.switchForm(active.guiFiles.get(index)); + } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/GuiBuilderMcpController.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/GuiBuilderMcpController.java new file mode 100644 index 00000000000..97e2288313b --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/GuiBuilderMcpController.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ai.Tool; +import com.codename1.ai.ToolHandler; +import com.codename1.io.JSONParser; +import com.codename1.mcp.MCP; +import com.codename1.mcp.MCPServer; +import com.codename1.ui.Display; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** GUI Builder domain tools layered over the portable MCP accessibility tools. */ +final class GuiBuilderMcpController { + private static final int MAX_EVENTS = 500; + private final CodenameOneGUIBuilder builder; + private final List> events = new ArrayList>(); + private long sequence; + + GuiBuilderMcpController(CodenameOneGUIBuilder builder) { + this.builder = builder; + } + + void register() { + MCPServer server = MCP.getServer(); + server.setServerInfo("codename-one-gui-builder", "8.0-SNAPSHOT"); + MCP.addTool(new Tool("guibuilder_state", + "Returns the active GUI document, selected component, preview bounds, layout constraints, " + + "undo/redo state and current drag guide.", + "{\"type\":\"object\",\"properties\":{}}", new ToolHandler() { + @Override public String invoke(String argumentsJson) { + return JSONParser.toJson(onEdtState()); + } + })); + MCP.addTool(new Tool("guibuilder_actions", + "Returns GUI Builder actions after a sequence number. timeoutMs optionally waits for live " + + "activity (maximum 10000ms). Use latestSequence as the next afterSequence value.", + "{\"type\":\"object\",\"properties\":{" + + "\"afterSequence\":{\"type\":\"integer\"}," + + "\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":10000}," + + "\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":500}}}", + new ToolHandler() { + @Override public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + long after = longValue(args.get("afterSequence"), 0); + int timeout = (int) Math.max(0, Math.min(10000, + longValue(args.get("timeoutMs"), 0))); + int limit = (int) Math.max(1, Math.min(MAX_EVENTS, + longValue(args.get("limit"), 100))); + return JSONParser.toJson(actionsAfter(after, timeout, limit)); + } + })); + MCP.addTool(new Tool("guibuilder_select", + "Selects a component in the active form by its GUI Builder name and returns fresh state. " + + "Set additive to true to add or toggle it in the current multi-selection.", + "{\"type\":\"object\",\"properties\":{" + + "\"component\":{\"type\":\"string\"}," + + "\"additive\":{\"type\":\"boolean\"}},\"required\":[\"component\"]}", + new ToolHandler() { + @Override public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + final String component = JSONParser.getString(args, "component"); + final boolean additive = booleanValue(args.get("additive")); + final boolean[] success = new boolean[1]; + runOnEdt(new Runnable() { + @Override public void run() { success[0] = builder.mcpSelectComponent(component, additive); } + }); + return resultWithState(success[0], success[0] ? null : "No component named " + component); + } + })); + MCP.addTool(new Tool("guibuilder_open_form", + "Opens a project GUI form by its relative or simple name. Refuses to discard unsaved changes.", + "{\"type\":\"object\",\"properties\":{" + + "\"form\":{\"type\":\"string\"}},\"required\":[\"form\"]}", + new ToolHandler() { + @Override public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + final String form = JSONParser.getString(args, "form"); + final String[] error = new String[1]; + runOnEdt(new Runnable() { + @Override public void run() { error[0] = builder.mcpOpenForm(form); } + }); + return resultWithState(error[0] == null, error[0]); + } + })); + MCP.addTool(new Tool("guibuilder_drag", + "Performs the GUI Builder's real pointer drag path for a named preview component. Supply " + + "absolute x/y coordinates from guibuilder_state, or a target and placement: " + + "center, before, after, above, below, leftOf, rightOf.", + "{\"type\":\"object\",\"properties\":{" + + "\"component\":{\"type\":\"string\"}," + + "\"target\":{\"type\":\"string\"}," + + "\"placement\":{\"type\":\"string\",\"enum\":[\"center\",\"before\",\"after\",\"above\",\"below\",\"leftOf\",\"rightOf\"]}," + + "\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}," + + "\"required\":[\"component\"]}", new ToolHandler() { + @Override public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + final String component = JSONParser.getString(args, "component"); + final String target = JSONParser.getString(args, "target"); + final String placement = JSONParser.getString(args, "placement"); + final Integer x = integerOrNull(args.get("x")); + final Integer y = integerOrNull(args.get("y")); + final String[] error = new String[1]; + runOnEdt(new Runnable() { + @Override public void run() { + error[0] = builder.mcpDragComponent(component, target, placement, x, y); + } + }); + // A successful drop schedules a fresh preview. A second EDT turn runs after it. + return resultWithState(error[0] == null, error[0]); + } + })); + MCP.addTool(new Tool("guibuilder_command", + "Runs a GUI Builder command and returns fresh state.", + "{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"," + + "\"enum\":[\"save\",\"undo\",\"redo\",\"refresh\",\"toggleDarkMode\"," + + "\"phonePortrait\",\"phoneLandscape\",\"tabletPortrait\",\"desktop\"]}}," + + "\"required\":[\"command\"]}", new ToolHandler() { + @Override public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + final String command = JSONParser.getString(args, "command"); + final String[] error = new String[1]; + runOnEdt(new Runnable() { + @Override public void run() { error[0] = builder.mcpCommand(command); } + }); + return resultWithState(error[0] == null, error[0]); + } + })); + } + + void record(String kind, Map details) { + Map event = new LinkedHashMap(); + synchronized (this) { + event.put("sequence", Long.valueOf(++sequence)); + event.put("timestamp", Long.valueOf(System.currentTimeMillis())); + event.put("kind", kind); + if (details != null) event.putAll(details); + events.add(event); + while (events.size() > MAX_EVENTS) events.remove(0); + notifyAll(); + } + } + + long latestSequence() { + synchronized (this) { return sequence; } + } + + private Map actionsAfter(long after, int timeout, int limit) throws InterruptedException { + synchronized (this) { + if (sequence <= after && timeout > 0) wait(timeout); + List outEvents = new ArrayList(); + // The cursor has to be the last event actually handed over, not the global sequence: + // clients are told to feed latestSequence back as afterSequence, so reporting the + // global one would silently skip every event this page could not fit. + long delivered = after; + for (int i = 0; i < events.size() && outEvents.size() < limit; i++) { + Map event = events.get(i); + if (longValue(event.get("sequence"), 0) > after) { + outEvents.add(new LinkedHashMap(event)); + delivered = longValue(event.get("sequence"), delivered); + } + } + Map out = new LinkedHashMap(); + out.put("latestSequence", Long.valueOf(outEvents.isEmpty() ? sequence : delivered)); + out.put("pendingEvents", Boolean.valueOf(delivered < sequence)); + out.put("events", outEvents); + return out; + } + } + + private String resultWithState(boolean success, String error) { + final Map result = new LinkedHashMap(); + result.put("success", Boolean.valueOf(success)); + if (error != null) result.put("error", error); + // A command queues refreshEditor(), which in turn queues the overlay refresh. Cross one + // EDT barrier before requesting state so both generations have completed and MCP never + // reports a new component rectangle with a stale selection rectangle. + runOnEdt(new Runnable() { @Override public void run() { } }); + result.put("state", onEdtState()); + return JSONParser.toJson(result); + } + + private Map onEdtState() { + final Map[] holder = new Map[1]; + runOnEdt(new Runnable() { + @Override public void run() { holder[0] = builder.mcpState(latestSequence()); } + }); + return holder[0]; + } + + private static void runOnEdt(Runnable runnable) { + Display display = Display.getInstance(); + if (display.isEdt()) runnable.run(); else display.callSeriallyAndWait(runnable); + } + + private static Map parse(String json) throws Exception { + if (json == null || json.length() == 0) return new LinkedHashMap(); + Map parsed = JSONParser.parseJSON(json); + return parsed == null ? new LinkedHashMap() : parsed; + } + + private static long longValue(Object value, long fallback) { + return value instanceof Number ? ((Number) value).longValue() : fallback; + } + + private static boolean booleanValue(Object value) { + return Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(String.valueOf(value)); + } + + private static Integer integerOrNull(Object value) { + return value instanceof Number ? Integer.valueOf(((Number) value).intValue()) : null; + } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/model/GuiDocument.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/model/GuiDocument.java new file mode 100644 index 00000000000..caf50188da7 --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/model/GuiDocument.java @@ -0,0 +1,922 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.model; + +import com.codename1.util.regex.StringReader; +import com.codename1.xml.Element; +import com.codename1.xml.XMLParser; +import com.codename1.xml.XMLWriter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class GuiDocument { + /** Single-name Guided Layout relationships. Mirrors the attributes GuidedLayoutSupport reads. */ + private static final String[] REFERENCE_NAME_ATTRIBUTES = { + "guidedMatchWidth", "guidedMatchHeight", "guidedReferenceTarget"}; + private static final String SIZE_MATCH = "match"; + private static final String SIZE_PREFERRED = "preferred"; + + private final String path; + private Element root; + private Element selected; + private boolean modified; + /** The XML as last written to disk; null until this document has been saved. */ + private String savedXml; + private final List undo = new ArrayList<>(); + private final List redo = new ArrayList<>(); + private int transactionDepth; + private State transactionStart; + + private GuiDocument(String path, Element root) { + this.path = path; + this.root = root; + this.selected = root; + } + + public static GuiDocument parse(String path, String xml) { + Element root = new XMLParser().parse(new StringReader(xml)); + if (root == null || !"component".equals(root.getTagName())) { + throw new IllegalArgumentException("GUI file does not contain a component root"); + } + return new GuiDocument(path, root); + } + + public String path() { return path; } + public Element root() { return root; } + public Element selected() { return selected; } + /** + * True when the document differs from what is on disk. This is derived from the saved XML + * rather than reported by a flag the undo stack restores: a snapshot taken before a save + * carries "not modified", so undoing across a save used to report a document that no longer + * matched the file as clean, and the next form switch then discarded it without a prompt. + */ + public boolean isModified() { + if (savedXml == null) return modified; + return !savedXml.equals(xmlOnly()); + } + + public void markSaved() { + modified = false; + savedXml = xmlOnly(); + } + + public void select(Element element) { + if (element != null && containsElement(element)) selected = element; + } + + /** Identity-based ownership check. Elements parsed for another form must never be accepted. */ + public boolean containsElement(Element element) { + return element != null && containsIdentity(root, element); + } + + public String attribute(String name, String fallback) { + String value = selected.getAttribute(name); + return value == null ? fallback : value; + } + + public void setAttribute(String name, String value) { + beforeMutation(); + setNormalizedAttribute(selected, name, value); + modified = true; + } + + /** + * Sets an attribute on a specific element rather than the selection. + * + * @param element the element to change + * @param name the attribute name + * @param value the new value, or null to remove it + */ + public void setAttribute(Element element, String name, String value) { + if (element == null) return; + beforeMutation(); + setNormalizedAttribute(element, name, value); + modified = true; + } + + public String effectiveUiid(Element element) { + String explicit = element == null ? null : element.getAttribute("uiid"); + if (explicit != null && explicit.length() > 0) return explicit; + String type = element == null ? null : element.getAttribute("type"); + return type == null || type.length() == 0 ? "Component" : type; + } + + public Element parentOf(Element element) { + return element == root ? null : findParent(root, element); + } + + public String parentLayout(Element element) { + Element parent = parentOf(element); + if (parent == null) return ""; + String layout = parent.getAttribute("layout"); + return layout == null || layout.length() == 0 ? "BoxLayout" : layout; + } + + /** Returns a valid, deterministic constraint even for malformed legacy BorderLayouts. */ + public static String effectiveBorderConstraint(Element parent, Element child) { + if (parent == null || child == null) return "Center"; + List used = new ArrayList<>(); + for (int i = 0; i < parent.getNumChildren(); i++) { + Object value = parent.getChildAt(i); + if (!(value instanceof Element) || !"component".equals(((Element) value).getTagName())) continue; + String constraint = normalizeBorderConstraint(((Element) value).getAttribute("layoutConstraint")); + if (constraint == null || used.contains(constraint)) constraint = firstFreeBorderConstraint(used); + if (((Element) value) == child) return constraint; + used.add(constraint); + } + return "Center"; + } + + public static Element childAtBorderConstraint(Element parent, String constraint, Element excluding) { + String wanted = normalizeBorderConstraint(constraint); + if (parent == null || wanted == null) return null; + for (int i = 0; i < parent.getNumChildren(); i++) { + Object value = parent.getChildAt(i); + if (value instanceof Element && "component".equals(((Element) value).getTagName()) && ((Element) value) != excluding + && wanted.equals(effectiveBorderConstraint(parent, ((Element) value)))) return ((Element) value); + } + return null; + } + + public static String normalizeBorderConstraint(String constraint) { + if (constraint == null) return null; + if ("north".equalsIgnoreCase(constraint)) return "North"; + if ("south".equalsIgnoreCase(constraint)) return "South"; + if ("east".equalsIgnoreCase(constraint)) return "East"; + if ("west".equalsIgnoreCase(constraint)) return "West"; + if ("center".equalsIgnoreCase(constraint)) return "Center"; + return null; + } + + private static String firstFreeBorderConstraint(List used) { + String[] order = {"Center", "North", "South", "West", "East"}; + for (String candidate : order) if (!used.contains(candidate)) return candidate; + return "Center"; + } + + public boolean moveSelectedBy(int delta) { + if (selected == root || delta == 0) return false; + Element parent = findParent(root, selected); + if (parent == null) return false; + int index = childIndex(parent, selected); + int target = index + delta; + if (index < 0 || target < 0 || target >= parent.getNumChildren()) return false; + beforeMutation(); + parent.removeChildAt(index); + parent.insertChildAt(selected, target); + modified = true; + return true; + } + + public List commands() { + List result = new ArrayList<>(); + for (int i = 0; i < root.getNumChildren(); i++) { + Object child = root.getChildAt(i); + if (child instanceof Element && "command".equals(((Element) child).getTagName())) result.add(((Element) child)); + } + return result; + } + + public Element addCommand() { + beforeMutation(); + Element command = new Element("command"); + command.setAttribute("name", "Command " + (commands().size() + 1)); + command.setAttribute("placement", "right"); + command.setAttribute("actionEvent", "onCommand" + (commands().size() + 1)); + root.addChild(command); + modified = true; + return command; + } + + public boolean removeCommand(Element command) { + if (command == null || !"command".equals(command.getTagName())) return false; + int index = childIndex(root, command); + if (index < 0) return false; + beforeMutation(); + root.removeChildAt(index); + modified = true; + return true; + } + + public void setCommandAttribute(Element command, String name, String value) { + if (command == null || !"command".equals(command.getTagName())) return; + beforeMutation(); + setNormalizedAttribute(command, name, value); + modified = true; + } + + /** + * True when the parent is a BorderLayout whose five regions are all taken. Core + * {@code BorderLayout.addLayoutComponent()} removes whatever already occupies a region, so a + * sixth child does not stack up -- it silently evicts an existing one from the preview and the + * generated container while remaining in the .gui hierarchy. + * + * @param parent the container an addition is aimed at + * @return true when there is no free region left + */ + public static boolean borderLayoutIsFull(Element parent) { + if (parent == null || !"BorderLayout".equals(parent.getAttribute("layout"))) return false; + return componentsIn(parent).size() >= BORDER_LAYOUT_REGIONS; + } + + /** North, South, East, West and Center: everything a BorderLayout can hold. */ + private static final int BORDER_LAYOUT_REGIONS = 5; + + /** + * @param type the component type to add + * @return the new element, or null when the selected parent cannot take another child + */ + public Element addComponent(String type) { + Element target = acceptsChildren(selected) ? selected : findParent(root, selected); + if (borderLayoutIsFull(target == null ? root : target)) return null; + beforeMutation(); + Element parent = selected; + if (!acceptsChildren(parent)) parent = findParent(root, selected); + if (parent == null) parent = root; + Element child = new Element("component"); + child.setAttribute("type", type); + child.setAttribute("name", uniqueName(type)); + if (acceptsChildren(child)) child.setAttribute("layout", "LayeredLayout"); + parent.addChild(child); + assignFreeTableCell(parent, child); + selected = child; + modified = true; + return child; + } + + /** + * Gives a new child of a table an explicit free cell. + * + *

Without this a component added from the palette or the menu carries no cell at all, and + * every consumer has to invent one. They did not agree: the preview fell back to sibling order + * while the generated source fell back to cell (0, 0), so a table that looked right in the + * designer compiled to every component stacked in one corner. + */ + private static void assignFreeTableCell(Element parent, Element child) { + if (!"TableLayout".equals(parent.getAttribute("layout"))) return; + int columns = tableColumns(parent); + Set taken = new LinkedHashSet<>(); + for (Element sibling : componentsIn(parent)) { + if (sibling == child) continue; + markOccupied(taken, parent, sibling); + } + for (int cursor = 0; ; cursor++) { + int row = cursor / columns; + int column = cursor % columns; + if (taken.contains(row + ":" + column)) continue; + setNormalizedAttribute(child, "tableRow", String.valueOf(row)); + setNormalizedAttribute(child, "tableColumn", String.valueOf(column)); + int declaredRows = parseInt(parent.getAttribute("tableLayoutRows"), 2); + if (declaredRows < row + 1) { + setNormalizedAttribute(parent, "tableLayoutRows", String.valueOf(row + 1)); + } + return; + } + } + + /** + * Marks every cell a sibling covers, not just the one it starts in. A component with a span + * greater than one occupies a rectangle; treating it as a single cell handed the next component + * a slot underneath it, and TableLayout then placed two children in the same space. + */ + private static void markOccupied(Set taken, Element parent, Element sibling) { + int row = effectiveTableRow(parent, sibling); + int column = effectiveTableColumn(parent, sibling); + int rowSpan = Math.max(1, parseInt(sibling.getAttribute("tableVerticalSpan"), 1)); + int columnSpan = Math.max(1, parseInt(sibling.getAttribute("tableHorizontalSpan"), 1)); + for (int r = row; r < row + rowSpan; r++) { + for (int c = column; c < column + columnSpan; c++) { + taken.add(r + ":" + c); + } + } + } + + /** The declared column count of a table, never below one. */ + public static int tableColumns(Element parent) { + return Math.max(1, parseInt(parent == null ? null : parent.getAttribute("tableLayoutColumns"), 2)); + } + + /** + * The row a table child occupies. Components loaded from a hand written .gui file may carry no + * cell; they fall back to sibling order so that the preview, the generated source and the + * inspector all place them identically. + */ + public static int effectiveTableRow(Element parent, Element child) { + Integer declared = parseIntOrNull(child == null ? null : child.getAttribute("tableRow")); + return declared != null ? Math.max(0, declared.intValue()) + : componentsIn(parent).indexOf(child) / tableColumns(parent); + } + + /** The column a table child occupies; see {@link #effectiveTableRow}. */ + public static int effectiveTableColumn(Element parent, Element child) { + Integer declared = parseIntOrNull(child == null ? null : child.getAttribute("tableColumn")); + int columns = tableColumns(parent); + return declared != null ? Math.max(0, Math.min(columns - 1, declared.intValue())) + : componentsIn(parent).indexOf(child) % columns; + } + + private static int parseInt(String value, int fallback) { + Integer parsed = parseIntOrNull(value); + return parsed == null ? fallback : parsed.intValue(); + } + + private static Integer parseIntOrNull(String value) { + if (value == null || value.trim().length() == 0) return null; + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException ex) { + return null; + } + } + + public boolean deleteSelected() { + if (selected == root) return false; + Element parent = findParent(root, selected); + if (parent == null) return false; + int index = childIndex(parent, selected); + if (index < 0) return false; + Element removed = selected; + beginTransaction(); + try { + parent.removeChildAt(index); + selected = parent; + // Guided Layout relationships are stored by name. Leaving a reference to a deleted + // component behind makes its dependents jump to an inset measured from nothing. + clearReferencesTo(root, namesIn(removed, new LinkedHashSet())); + modified = true; + } finally { + endTransaction(); + } + return true; + } + + /** + * Renames the selected component, guaranteeing a unique name and repointing every Guided + * Layout relationship that referenced the old name, as one undo step. Returns the name that + * was actually applied, which differs from the request when the request was already taken. + */ + public String renameSelected(String requestedName) { + Element target = selected; + if (target == null || !"component".equals(target.getTagName())) return null; + String previous = target.getAttribute("name"); + String requested = requestedName == null ? "" : requestedName.trim(); + if (requested.length() == 0 || requested.equals(previous)) return previous; + Set exclude = new LinkedHashSet<>(); + exclude.add(target); + String unique = availableName(requested, exclude, null); + beginTransaction(); + try { + setNormalizedAttribute(target, "name", unique); + if (previous != null && previous.length() > 0) { + Map renames = new LinkedHashMap<>(); + renames.put(previous, unique); + remapReferences(root, renames); + } + modified = true; + } finally { + endTransaction(); + } + return unique; + } + + public String copySelectedXml() { + return new XMLWriter(true).toXML(selected); + } + + public Element pasteXml(String xml) { + if (xml == null || xml.length() == 0) return null; + Element pasted = new XMLParser().parse(new StringReader(xml)); + if (pasted == null || !"component".equals(pasted.getTagName())) return null; + beginTransaction(); + try { + if (pasted.getParent() != null) removeChild(pasted.getParent(), pasted); + Element parent = acceptsChildren(selected) ? selected : findParent(root, selected); + if (parent == null) parent = root; + uniquifyPastedNames(pasted); + parent.addChild(pasted); + // Pasted XML keeps the cell it was copied from, so pasting a table child back into its + // own table put two components in one cell. The cell it carries is dropped and a free + // one assigned, exactly as for a component added from the palette. + if ("TableLayout".equals(parent.getAttribute("layout"))) { + setNormalizedAttribute(pasted, "tableRow", null); + setNormalizedAttribute(pasted, "tableColumn", null); + assignFreeTableCell(parent, pasted); + } + selected = pasted; + modified = true; + } finally { + endTransaction(); + } + return pasted; + } + + /** + * A pasted subtree keeps its internal relationships but must not collide with names that are + * already used: every component in it is renamed where necessary and the references inside the + * subtree follow. Names outside the subtree keep pointing at the original components. + */ + private void uniquifyPastedNames(Element pasted) { + List elements = new ArrayList<>(); + collect(pasted, elements); + Set exclude = new LinkedHashSet<>(elements); + Set assigned = new LinkedHashSet<>(); + Map renames = new LinkedHashMap<>(); + for (Element element : elements) { + String current = element.getAttribute("name"); + String type = element.getAttribute("type"); + String base = current == null || current.length() == 0 + ? lowerFirst(type == null || type.length() == 0 ? "component" : type) : current; + String unique = availableName(base, exclude, assigned); + assigned.add(unique); + if (unique.equals(current)) continue; + setNormalizedAttribute(element, "name", unique); + if (current != null && current.length() > 0) renames.put(current, unique); + } + if (!renames.isEmpty()) remapReferences(pasted, renames); + } + + /** Returns `requested` when it is free, otherwise the same base with the lowest free suffix. */ + private String availableName(String requested, Set exclude, Set reserved) { + if (isNameAvailable(requested, exclude, reserved)) return requested; + String base = requested; + while (base.length() > 1 && base.charAt(base.length() - 1) >= '0' + && base.charAt(base.length() - 1) <= '9') { + base = base.substring(0, base.length() - 1); + } + for (int index = 1; index < 10000; index++) { + String candidate = base + index; + if (isNameAvailable(candidate, exclude, reserved)) return candidate; + } + return requested; + } + + private boolean isNameAvailable(String name, Set exclude, Set reserved) { + if (reserved != null && reserved.contains(name)) return false; + return !containsName(root, name, exclude); + } + + private static void remapReferences(Element element, Map renames) { + String[] references = splitReferences(element); + if (references != null) { + boolean changed = false; + for (int i = 0; i < references.length; i++) { + String replacement = renames.get(references[i].trim()); + if (replacement == null) continue; + references[i] = replacement; + changed = true; + } + if (changed) setNormalizedAttribute(element, "guidedReferences", joinReferences(references)); + } + for (String attribute : REFERENCE_NAME_ATTRIBUTES) { + String value = element.getAttribute(attribute); + String replacement = value == null ? null : renames.get(value.trim()); + if (replacement != null) setNormalizedAttribute(element, attribute, replacement); + } + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element) remapReferences(((Element) child), renames); + } + } + + private static void clearReferencesTo(Element element, Set removedNames) { + String[] references = splitReferences(element); + if (references != null) { + boolean changed = false; + for (int i = 0; i < references.length; i++) { + if (!removedNames.contains(references[i].trim())) continue; + references[i] = "-"; + changed = true; + } + if (changed) setNormalizedAttribute(element, "guidedReferences", joinReferences(references)); + } + clearMatchReference(element, "guidedMatchWidth", "guidedHorizontalSize", removedNames); + clearMatchReference(element, "guidedMatchHeight", "guidedVerticalSize", removedNames); + String target = element.getAttribute("guidedReferenceTarget"); + if (target != null && removedNames.contains(target.trim())) { + setNormalizedAttribute(element, "guidedReferenceTarget", null); + } + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element) clearReferencesTo(((Element) child), removedNames); + } + } + + /** A match policy whose target is gone falls back to the component's own preferred size. */ + private static void clearMatchReference(Element element, String matchAttribute, + String policyAttribute, Set removedNames) { + String value = element.getAttribute(matchAttribute); + if (value == null || !removedNames.contains(value.trim())) return; + setNormalizedAttribute(element, matchAttribute, null); + if (SIZE_MATCH.equals(element.getAttribute(policyAttribute))) { + setNormalizedAttribute(element, policyAttribute, SIZE_PREFERRED); + } + } + + private static String[] splitReferences(Element element) { + String raw = element.getAttribute("guidedReferences"); + return raw == null || raw.length() == 0 ? null : raw.split("\\|", -1); + } + + private static String joinReferences(String[] references) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < references.length; i++) { + if (i > 0) out.append('|'); + out.append(references[i] == null || references[i].trim().length() == 0 + ? "-" : references[i].trim()); + } + return out.toString(); + } + + private static Set namesIn(Element element, Set out) { + String name = element.getAttribute("name"); + if (name != null && name.length() > 0) out.add(name); + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) { + namesIn(((Element) child), out); + } + } + return out; + } + + public boolean moveSelectedTo(Element target) { + return moveSelectedTo(target, false); + } + + public boolean moveSelectedTo(Element target, boolean afterTarget) { + if (selected == root || target == null || target == selected) return false; + Element oldParent = findParent(root, selected); + Element targetParent = findParent(root, target); + if (oldParent == null) return false; + Element destination = acceptsChildren(target) ? target : targetParent; + if (destination == null || isDescendant(selected, destination)) return false; + beforeMutation(); + int targetIndex = destination.getNumChildren(); + if (destination == targetParent) { + for (int i = 0; i < destination.getNumChildren(); i++) { + if (destination.getChildAt(i) == target) { + targetIndex = i + (afterTarget ? 1 : 0); + break; + } + } + } + int oldIndex = destination == oldParent ? childIndex(oldParent, selected) : -1; + removeChild(oldParent, selected); + if (oldIndex >= 0 && oldIndex < targetIndex) targetIndex--; + if (destination == oldParent && targetIndex > destination.getNumChildren()) targetIndex = destination.getNumChildren(); + destination.insertChildAt(selected, targetIndex); + modified = true; + return true; + } + + public int componentIndex(Element parent, Element child) { + if (parent == null || child == null) return -1; + int componentIndex = 0; + for (int i = 0; i < parent.getNumChildren(); i++) { + Object value = parent.getChildAt(i); + if (!(value instanceof Element) || !"component".equals(((Element) value).getTagName())) continue; + if (((Element) value) == child) return componentIndex; + componentIndex++; + } + return -1; + } + + public boolean moveSelectedToParent(Element destination, int componentIndex) { + if (selected == root || destination == null || !acceptsChildren(destination) + || selected == destination || isDescendant(selected, destination)) return false; + Element oldParent = findParent(root, selected); + if (oldParent == null) return false; + beforeMutation(); + removeChild(oldParent, selected); + int xmlIndex = destination.getNumChildren(); + int seen = 0; + for (int i = 0; i < destination.getNumChildren(); i++) { + Object child = destination.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) { + if (seen >= componentIndex) { xmlIndex = i; break; } + seen++; + } + } + destination.insertChildAt(selected, Math.max(0, Math.min(xmlIndex, destination.getNumChildren()))); + modified = true; + return true; + } + + public List components() { + List result = new ArrayList<>(); + collect(root, result); + return result; + } + + /** The direct component children of {@code parent}, in document order, skipping non-component tags. */ + public static List componentsIn(Element parent) { + List result = new ArrayList<>(); + if (parent == null) return result; + for (int i = 0; i < parent.getNumChildren(); i++) { + Object child = parent.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) result.add(((Element) child)); + } + return result; + } + + public String toXml() { + return "\n\n" + xmlOnly(); + } + + public boolean canUndo() { return !undo.isEmpty(); } + public boolean canRedo() { return !redo.isEmpty(); } + + public void beginTransaction() { + if (transactionDepth++ == 0) transactionStart = capture(); + } + + public void endTransaction() { + if (transactionDepth == 0) return; + if (--transactionDepth == 0) { + if (transactionStart != null && !transactionStart.xml.equals(xmlOnly())) { + undo.add(transactionStart); + redo.clear(); + } + transactionStart = null; + } + } + + public boolean undo() { + if (!canUndo()) return false; + redo.add(capture()); + restore(undo.remove(undo.size() - 1)); + return true; + } + + public boolean redo() { + if (!canRedo()) return false; + undo.add(capture()); + restore(redo.remove(redo.size() - 1)); + return true; + } + + public static boolean acceptsChildren(Element element) { + String type = element == null ? null : element.getAttribute("type"); + return "Form".equals(type) || "Container".equals(type) || "Dialog".equals(type) + || "Tabs".equals(type) || "Accordion".equals(type); + } + + private String uniqueName(String type) { + int index = 1; + while (containsName(root, lowerFirst(type) + index, null)) index++; + return lowerFirst(type) + index; + } + + private static String lowerFirst(String value) { + return value.substring(0, 1).toLowerCase() + value.substring(1); + } + + private static boolean containsName(Element element, String name, Set exclude) { + if ((exclude == null || !exclude.contains(element)) && name.equals(element.getAttribute("name"))) { + return true; + } + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element && containsName(((Element) child), name, exclude)) return true; + } + return false; + } + + private static Element findParent(Element parent, Element target) { + for (int i = 0; i < parent.getNumChildren(); i++) { + Object child = parent.getChildAt(i); + if (child == target) return parent; + if (child instanceof Element) { + Element found = findParent(((Element) child), target); + if (found != null) return found; + } + } + return null; + } + + private static void removeChild(Element parent, Element child) { + for (int i = 0; i < parent.getNumChildren(); i++) { + if (parent.getChildAt(i) == child) { + parent.removeChildAt(i); + return; + } + } + } + + private static int childIndex(Element parent, Element child) { + for (int i = 0; i < parent.getNumChildren(); i++) { + if (parent.getChildAt(i) == child) return i; + } + return -1; + } + + private static boolean isDescendant(Element ancestor, Element candidate) { + if (ancestor == candidate) return true; + for (int i = 0; i < ancestor.getNumChildren(); i++) { + Object child = ancestor.getChildAt(i); + if (child instanceof Element && isDescendant(((Element) child), candidate)) return true; + } + return false; + } + + private static boolean containsIdentity(Element parent, Element target) { + if (parent == target) return true; + for (int i = 0; i < parent.getNumChildren(); i++) { + Object child = parent.getChildAt(i); + if (child instanceof Element && containsIdentity(((Element) child), target)) return true; + } + return false; + } + + private static void collect(Element element, List result) { + result.add(element); + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) collect(((Element) child), result); + } + } + + /** Attributes whose value is the user's own text. Trimming these, or dropping them when they + * are set to the empty string, changes what the form says: the leading spaces in a label + * disappear, and clearing a text field brings the sample placeholder back instead of leaving it + * empty. Structural attributes stay normalized because a stray space in a cell index or a + * layout name is never intentional. */ + private static final String TEXTUAL_ATTRIBUTES = "|text|hint|title|"; + + /** XMLParser stores case-insensitive attribute keys in lowercase. Always mutate that canonical + * key and remove a possible camel-case duplicate created by older GUI Builder versions. */ + private static void setNormalizedAttribute(Element element, String name, String value) { + String canonical = name.toLowerCase(); + element.removeAttribute(canonical); + if (!canonical.equals(name)) element.removeAttribute(name); + if (value == null) return; + if (TEXTUAL_ATTRIBUTES.indexOf("|" + canonical + "|") >= 0) { + element.setAttribute(canonical, value); + return; + } + if (value.trim().length() > 0) element.setAttribute(canonical, value.trim()); + } + + private void beforeMutation() { + if (transactionDepth > 0) return; + undo.add(capture()); + redo.clear(); + } + + private State capture() { + return new State(xmlOnly(), selected == null ? null : selected.getAttribute("name"), modified); + } + + /** + * Serializes the tree with attributes in a stable order. + * + *

Element stores attributes in a Hashtable, so XMLWriter emits them in whatever order that + * table happens to iterate. Two trees describing the same form then produce different text, + * which made undo comparison unreliable -- an edit-then-undo round trip reported a change that + * had not happened -- and made every save churn unrelated lines in the .gui file. Ordering is + * type, name, layout, then the rest alphabetically: the identifying attributes first, so the + * files stay readable. + */ + private String xmlOnly() { + StringBuilder out = new StringBuilder(); + writeElement(root, 0, out); + return out.toString(); + } + + private static final String[] LEADING_ATTRIBUTES = {"type", "name", "layout"}; + + private static void writeElement(Element element, int depth, StringBuilder out) { + // String.repeat is outside the Codename One runtime API the compliance check enforces. + StringBuilder indentBuilder = new StringBuilder(); + for (int i = 0; i < depth; i++) indentBuilder.append('\t'); + String indent = indentBuilder.toString(); + out.append(indent).append('<').append(element.getTagName()); + for (String key : orderedAttributeNames(element)) { + out.append(' ').append(key).append("=\"").append(escape(element.getAttribute(key))).append('"'); + } + // Every tag, not just components: a form also carries children for its toolbar, + // and dropping them here would delete the toolbar on the next save. + List children = new ArrayList<>(); + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element && ((Element) child).getTagName() != null) children.add(((Element) child)); + } + if (children.isEmpty()) { + out.append(" />\n"); + return; + } + out.append(">\n"); + for (Element child : children) writeElement(child, depth + 1, out); + out.append(indent).append("\n"); + } + + private static List orderedAttributeNames(Element element) { + java.util.Hashtable attributes = element.getAttributes(); + List rest = new ArrayList<>(); + if (attributes != null) { + for (java.util.Enumeration keys = attributes.keys(); keys.hasMoreElements();) { + rest.add(String.valueOf(keys.nextElement())); + } + } + java.util.Collections.sort(rest); + List ordered = new ArrayList<>(); + for (String leading : LEADING_ATTRIBUTES) { + if (rest.remove(leading)) ordered.add(leading); + } + ordered.addAll(rest); + return ordered; + } + + private static String escape(String value) { + if (value == null) return ""; + StringBuilder out = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '&': + out.append("&"); + break; + case '<': + out.append("<"); + break; + case '>': + out.append(">"); + break; + case '"': + out.append("""); + break; + // XML attribute normalization turns a literal newline, carriage return or tab into + // a space when the file is read back, so a multi line TextArea value quietly + // collapsed onto one line the first time the form was saved and reopened. + case '\n': + out.append(" "); + break; + case '\r': + out.append(" "); + break; + case '\t': + out.append(" "); + break; + default: + out.append(c); + break; + } + } + return out.toString(); + } + + private void restore(State state) { + root = new XMLParser().parse(new StringReader(state.xml)); + selected = state.selectedName == null ? root : findByName(root, state.selectedName); + if (selected == null) selected = root; + modified = state.modified; + } + + private static Element findByName(Element element, String name) { + if (name.equals(element.getAttribute("name"))) return element; + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element) { + Element found = findByName((Element) child, name); + if (found != null) return found; + } + } + return null; + } + + private static final class State { + final String xml; + final String selectedName; + final boolean modified; + State(String xml, String selectedName, boolean modified) { + this.xml = xml; + this.selectedName = selectedName; + this.modified = modified; + } + } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java new file mode 100644 index 00000000000..7987974f76b --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectBinding.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.project; + +public final class ProjectBinding { + private String projectDir; + private String guiDir; + private String sourceDir; + private String cssFile; + private String initialForm; + + public String projectDir() { return projectDir; } + public String guiDir() { return guiDir; } + public String sourceDir() { return sourceDir; } + public String cssFile() { return cssFile; } + public String initialForm() { return initialForm; } + public boolean isValid() { return projectDir != null && guiDir != null; } + + public static ProjectBinding parse(String content) { + ProjectBinding binding = new ProjectBinding(); + if (content == null) return binding; + for (String line : content.replace("\r\n", "\n").split("\n")) { + String value = line.trim(); + if (value.length() == 0 || value.startsWith("#")) continue; + int split = value.indexOf('='); + if (split < 1) continue; + String key = value.substring(0, split).trim(); + String field = value.substring(split + 1).trim(); + switch (key) { + case "projectDir": + binding.projectDir = field; + break; + case "guiDir": + binding.guiDir = field; + break; + case "sourceDir": + binding.sourceDir = field; + break; + case "cssFile": + binding.cssFile = field; + break; + case "initialForm": + binding.initialForm = field; + break; + default: + break; + } + } + return binding; + } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java new file mode 100644 index 00000000000..2fcf1d28a4c --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/project/ProjectIO.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.project; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.Util; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class ProjectIO { + public static final String INPUT_PROPERTY = "guibuilder.input"; + + private ProjectIO() { } + + public static ProjectBinding loadBinding() { + String path = System.getProperty(INPUT_PROPERTY); + if (path == null || path.trim().length() == 0) return null; + try { + String content = read(path); + ProjectBinding binding = ProjectBinding.parse(content); + return binding.isValid() ? binding : null; + } catch (IOException ex) { + return null; + } + } + + public static List findGuiFiles(String guiDir) { + List files = new ArrayList<>(); + collect(fsUrl(guiDir), files); + Collections.sort(files); + return files; + } + + private static void collect(String dir, List files) { + FileSystemStorage fs = FileSystemStorage.getInstance(); + if (dir == null || !fs.exists(dir) || !fs.isDirectory(dir)) return; + try { + for (String child : fs.listFiles(dir)) { + String path = dir + (dir.endsWith("/") ? "" : "/") + child; + if (fs.isDirectory(path)) collect(path, files); + else if (path.endsWith(".gui")) files.add(path); + } + } catch (IOException ignored) { } + } + + public static String read(String path) throws IOException { + InputStream in = null; + try { + in = FileSystemStorage.getInstance().openInputStream(fsUrl(path)); + return Util.readToString(in, "UTF-8"); + } finally { + Util.cleanup(in); + } + } + + public static boolean exists(String path) { + return path != null && FileSystemStorage.getInstance().exists(fsUrl(path)); + } + + /** + * Writes through a sibling temporary file so a failed write cannot destroy the previous + * content. Opening the target directly truncates it before the first byte arrives, which turns + * a full disk or a killed process into an empty form, stylesheet or source file. + */ + public static void write(String path, String content) throws IOException { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String url = fsUrl(path); + ensureParent(url); + String temporary = url + ".cn1tmp"; + OutputStream out = null; + try { + out = fs.openOutputStream(temporary); + out.write(content.getBytes("UTF-8")); + out.close(); + out = null; + } catch (IOException ex) { + Util.cleanup(out); + fs.delete(temporary); + throw ex; + } finally { + Util.cleanup(out); + } + if (!fs.exists(temporary)) { + throw new IOException("Failed to write " + path); + } + // rename() cannot replace an existing file on every platform, so the target is removed + // first. The window this opens is one rename wide and only after the new content is + // safely on disk, where the truncating write left it open for the whole serialization. + if (fs.exists(url)) { + fs.delete(url); + } + if (fs.exists(url)) { + // delete() and rename() both fail silently, and the target that survives a failed + // replacement still satisfies an exists() check, so a caller would be told the save + // succeeded while the new content sat in the temporary file. + fs.delete(temporary); + throw new IOException("Could not replace " + path + "; it may be open in another program"); + } + fs.rename(temporary, fileName(url)); + if (!fs.exists(url) || fs.exists(temporary)) { + throw new IOException("Failed to replace " + path + " with the file just written"); + } + } + + private static String fileName(String url) { + int slash = url.lastIndexOf('/'); + return slash < 0 ? url : url.substring(slash + 1); + } + + private static void ensureParent(String path) { + int slash = path.lastIndexOf('/'); + if (slash <= "file://".length()) return; + String parent = path.substring(0, slash); + FileSystemStorage fs = FileSystemStorage.getInstance(); + if (fs.exists(parent)) return; + ensureParent(parent); + fs.mkdir(parent); + } + + /** + * Normalizes to forward slashes before building the URL. The Maven plugin hands this editor + * native paths, so on Windows every path arrives with backslashes; leaving them in place makes + * the separator arithmetic in {@code ensureParent} and {@code fileName} silently find nothing. + * A drive-lettered path becomes {@code file://C:/...}, which the JavaSE port maps back to + * {@code C:\...}. + */ + public static String fsUrl(String path) { + if (path == null) return null; + String normalized = path.replace('\\', '/'); + if (normalized.startsWith("file://") || normalized.indexOf("://") > 0) return normalized; + return "file://" + normalized; + } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/ComponentPreviewFactory.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/ComponentPreviewFactory.java new file mode 100644 index 00000000000..22f784a55d2 --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/ComponentPreviewFactory.java @@ -0,0 +1,414 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.guibuilder.model.GuiDocument; +import com.codename1.ui.Button; +import com.codename1.ui.CheckBox; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.FontImage; +import com.codename1.ui.RadioButton; +import com.codename1.ui.Slider; +import com.codename1.ui.Tabs; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.accessibility.AccessibilityGrouping; +import com.codename1.ui.accessibility.AccessibilityRole; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.PointerEvent; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.table.TableLayout; +import com.codename1.xml.Element; + +public final class ComponentPreviewFactory { + private static final String MAX_DESIGN_WIDTH = "gui.maxDesignWidth"; + private static final String MAX_DESIGN_HEIGHT = "gui.maxDesignHeight"; + public interface SelectionHandler { + void selected(Element element); + default void selected(Element element, boolean additive) { selected(element); } + void dragPressed(Element element, Component source, int x, int y); + boolean isDragActive(); + void editContent(Element element); + default void dragMoved(int x, int y) { } + default void dragReleased(int x, int y) { } + } + + private ComponentPreviewFactory() { } + + public static Component create(Element element, Element selected, SelectionHandler handler) { + String type = value(element, "type", "Container"); + int guidedWidth = integer(element, "guidedPreferredWidth", -1); + int guidedHeight = integer(element, "guidedPreferredHeight", -1); + Component component; + switch (type) { + case "Button": + component = new Button(value(element, "text", "Button")) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "Label": + component = new Label(value(element, "text", "Label")) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "SpanLabel": + component = new SpanLabel(value(element, "text", "Wrapped label text")) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "TextField": + component = new TextField(value(element, "text", ""), value(element, "hint", "Text field")) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "TextArea": + component = new TextArea(value(element, "text", "Text area")) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "CheckBox": + component = new CheckBox(value(element, "text", "Check box")) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "RadioButton": + component = new RadioButton(value(element, "text", "Radio button")) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "Slider": + component = new Slider() { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + break; + case "Tabs": + component = sizedTabs(element, selected, handler, guidedWidth, guidedHeight); + break; + default: + component = sizedContainer(element, selected, handler, guidedWidth, guidedHeight); + break; + } + component.setUIID(value(element, "uiid", type)); + applyAttributes(component, element); + if (component instanceof CheckBox) { + ((CheckBox) component).setIcon(FontImage.createMaterial(((CheckBox) component).isSelected() + ? FontImage.MATERIAL_CHECK_BOX : FontImage.MATERIAL_CHECK_BOX_OUTLINE_BLANK, + ((CheckBox) component).getUnselectedStyle())); + } else if (component instanceof RadioButton) { + ((RadioButton) component).setIcon(FontImage.createMaterial(((RadioButton) component).isSelected() + ? FontImage.MATERIAL_RADIO_BUTTON_CHECKED : FontImage.MATERIAL_RADIO_BUTTON_UNCHECKED, + ((RadioButton) component).getUnselectedStyle())); + } + component.putClientProperty("gui.originalBorder", component.getUnselectedStyle().getBorder()); + component.putClientProperty("gui.element", element); + String componentName = value(element, "name", type); + component.setName("preview." + componentName); + component.getSemantics().setIdentifier("guibuilder.preview." + componentName) + .setLabel(componentName + ", " + type) + .setDescription("Component on the GUI Builder design canvas") + .setGrouping(component instanceof Container ? AccessibilityGrouping.GROUP : AccessibilityGrouping.AUTO); + if (component instanceof Container) component.getSemantics().setRole(AccessibilityRole.GENERIC); + component.setDropTarget(false); + component.addPointerPressedListener(e -> { + handler.selected(element, additiveSelection(e)); + if (!"Form".equals(type)) handler.dragPressed(element, component, e.getX(), e.getY()); + }); + if (supportsInlineContent(type)) component.addLongPressListener(e -> handler.editContent(element)); + component.addPointerDraggedListener(e -> handler.dragMoved(e.getX(), e.getY())); + component.addPointerReleasedListener(e -> { + boolean completedDrag = handler.isDragActive(); + handler.dragReleased(e.getX(), e.getY()); + if (completedDrag) return; + if (!supportsInlineContent(type)) return; + long now = System.currentTimeMillis(); + Object previous = component.getClientProperty("gui.lastClick"); + component.putClientProperty("gui.lastClick", Long.valueOf(now)); + if (previous instanceof Long && now - ((Long) previous).longValue() < 450) handler.editContent(element); + }); + return component; + } + + public static void stabilizeDesignStyles(Component component) { + component.setEnabled(true); + component.setFocusable(false); + component.setRippleEffect(false); + component.setPressedStyle(new Style(component.getUnselectedStyle())); + component.setSelectedStyle(new Style(component.getUnselectedStyle())); + if (component instanceof Container) { + for (int i = 0; i < ((Container) component).getComponentCount(); i++) stabilizeDesignStyles(((Container) component).getComponentAt(i)); + } + } + + private static Component sizedContainer(Element element, Element selected, SelectionHandler handler, + int guidedWidth, int guidedHeight) { + Container out = new Container(layout(element)) { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + // The UIID is applied by create() from the element's own uiid/type so the preview is + // styled by the project CSS exactly as the generated form will be at runtime. + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) { + Component rendered = create(((Element) child), selected, handler); + if (out.getLayout() instanceof BorderLayout) { + out.add(GuiDocument.effectiveBorderConstraint(element, ((Element) child)), rendered); + } else if (out.getLayout() instanceof TableLayout) { + // GuiDocument owns the cell rule so the preview and the generated source cannot + // disagree about where a component with no explicit cell belongs. + int row = GuiDocument.effectiveTableRow(element, ((Element) child)); + int column = GuiDocument.effectiveTableColumn(element, ((Element) child)); + TableLayout.Constraint constraint = ((TableLayout) out.getLayout()).createConstraint(row, column) + .horizontalSpan(integer(((Element) child), "tableHorizontalSpan", 1)) + .verticalSpan(integer(((Element) child), "tableVerticalSpan", 1)); + int width = integer(((Element) child), "tableWidth", -1); + int height = integer(((Element) child), "tableHeight", -1); + if (width != -1) constraint.widthPercentage(width); + if (height != -1) constraint.heightPercentage(height); + out.add(constraint, rendered); + } else { + out.add(rendered); + } + } + } + if (out.getLayout() instanceof LayeredLayout) GuidedLayoutSupport.apply(element, out); + if (out.getComponentCount() == 0) out.add(emptyContainerHint()); + return out; + } + + /** + * The marker shown for a container with no children. + * + *

Its preferred size is deliberately small and fixed. As an ordinary label it asked for + * whatever width its text needed, which is wider than a real empty container would ever be -- + * enough that emptying one column of a horizontal box pushed the next column past the edge of + * the device, so draining a container appeared to delete every component on the form. The hint + * exists to mark a drop target, so it must never be the thing that decides a layout. + */ + private static Component emptyContainerHint() { + Label hint = new Label("Drop here", "BuilderEmptyHint") { + @Override protected Dimension calcPreferredSize() { + int width = Display.getInstance().convertToPixels(8); + int height = Display.getInstance().convertToPixels(4); + return new Dimension(width, height); + } + }; + hint.setEndsWith3Points(true); + hint.setShowEvenIfBlank(true); + return hint; + } + + private static boolean additiveSelection(ActionEvent event) { + PointerEvent pointer = event == null ? null : event.getPointerEvent(); + return pointer != null && (pointer.isShiftDown() || pointer.isControlDown() || pointer.isMetaDown()); + } + + private static Component sizedTabs(Element element, Element selected, SelectionHandler handler, + int guidedWidth, int guidedHeight) { + Tabs tabs = new Tabs() { + @Override protected Dimension calcPreferredSize() { + return guidedSize(this, super.calcPreferredSize(), guidedWidth, guidedHeight); + } + }; + for (int i = 0; i < element.getNumChildren(); i++) { + Object child = element.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) { + tabs.addTab(value(((Element) child), "name", "Tab " + (i + 1)), create(((Element) child), selected, handler)); + } + } + if (tabs.getTabCount() == 0) tabs.addTab("Tab", new Label("Drop content here")); + return tabs; + } + + private static Layout layout(Element element) { + String layout = value(element, "layout", "BoxLayout"); + switch (layout) { + case "BorderLayout": + return new DesignerBorderLayout(); + case "FlowLayout": + return new FlowLayout(); + case "GridLayout": + return new GridLayout(integer(element, "gridLayoutRows", 1), integer(element, "gridLayoutColumns", 2)); + case "TableLayout": + return new TableLayout(integer(element, "tableLayoutRows", 2), integer(element, "tableLayoutColumns", 2)); + case "LayeredLayout": + return new LayeredLayout(); + default: + return "X".equals(element.getAttribute("boxLayoutAxis")) ? BoxLayout.x() : BoxLayout.y(); + } + } + + /** Keeps a newly displaced edge component from consuming the entire designer surface. */ + private static final class DesignerBorderLayout extends BorderLayout { + @Override public void layoutContainer(Container parent) { + int contentWidth = Math.max(1, parent.getWidth() - parent.getStyle().getHorizontalPadding()); + int contentHeight = Math.max(1, parent.getHeight() - parent.getStyle().getVerticalPadding()); + capWidth(getWest(), Math.max(48, contentWidth * 30 / 100)); + capWidth(getEast(), Math.max(48, contentWidth * 30 / 100)); + capHeight(getNorth(), Math.max(36, contentHeight * 30 / 100)); + capHeight(getSouth(), Math.max(36, contentHeight * 30 / 100)); + super.layoutContainer(parent); + } + + private static void capWidth(Component component, int maximum) { + if (component == null) return; + component.putClientProperty(MAX_DESIGN_WIDTH, Integer.valueOf(maximum)); + component.setShouldCalcPreferredSize(true); + } + + private static void capHeight(Component component, int maximum) { + if (component == null) return; + component.putClientProperty(MAX_DESIGN_HEIGHT, Integer.valueOf(maximum)); + component.setShouldCalcPreferredSize(true); + } + } + + private static int integer(Element element, String name, int fallback) { + try { return Integer.parseInt(value(element, name, String.valueOf(fallback))); } + catch (NumberFormatException ex) { return fallback; } + } + + private static Dimension guidedSize(Component component, Dimension natural, int guidedWidth, int guidedHeight) { + if (guidedWidth > 0) natural.setWidth(guidedWidth); + if (guidedHeight > 0) natural.setHeight(guidedHeight); + Object maximumWidth = component.getClientProperty(MAX_DESIGN_WIDTH); + Object maximumHeight = component.getClientProperty(MAX_DESIGN_HEIGHT); + if (maximumWidth instanceof Integer) natural.setWidth(Math.min(natural.getWidth(), ((Integer) maximumWidth).intValue())); + if (maximumHeight instanceof Integer) natural.setHeight(Math.min(natural.getHeight(), ((Integer) maximumHeight).intValue())); + return natural; + } + + /** See CodenameOneGUIBuilder#value: an emptied attribute is a value, not a missing one. */ + private static String value(Element element, String name, String fallback) { + String value = element.getAttribute(name); + return value == null ? fallback : value; + } + + private static boolean supportsInlineContent(String type) { + return "Form".equals(type) || "Label".equals(type) || "SpanLabel".equals(type) + || "Button".equals(type) || "CheckBox".equals(type) || "RadioButton".equals(type) + || "TextField".equals(type) || "TextArea".equals(type); + } + + private static void applyAttributes(Component component, Element element) { + component.setEnabled(!"false".equals(value(element, "enabled", "true"))); + component.setVisible(!"false".equals(value(element, "visible", "true"))); + component.setRTL("true".equals(value(element, "rtl", "false"))); + if (component instanceof Label) { + ((Label) component).setGap(integer(element, "gap", ((Label) component).getGap())); + ((Label) component).setTickerEnabled("true".equals(value(element, "tickerEnabled", "false"))); + } else if (component instanceof SpanLabel) { + // SpanLabel is a Container rather than a Label, so the inspector's gap silently did + // nothing here while the generated source was free to set it. + ((SpanLabel) component).setGap(integer(element, "gap", ((SpanLabel) component).getGap())); + } + // Applied only when the document carries it: the generated source emits nothing for an + // absent attribute, and forcing LEFT here would make the preview disagree with a themed + // alignment. Labels and text inputs are the components that own the setter. + if (element.getAttribute("alignment") != null) { + if (component instanceof Label) ((Label) component).setAlignment(alignment(element.getAttribute("alignment"))); + else if (component instanceof TextArea) ((TextArea) component).setAlignment(alignment(element.getAttribute("alignment"))); + } + if (component instanceof Button) ((Button) component).setToggle("true".equals(value(element, "toggle", "false"))); + if (component instanceof CheckBox) ((CheckBox) component).setSelected("true".equals(value(element, "selected", "false"))); + else if (component instanceof RadioButton) ((RadioButton) component).setSelected("true".equals(value(element, "selected", "false"))); + if (component instanceof TextArea) { + ((TextArea) component).setColumns(integer(element, "columns", ((TextArea) component).getColumns())); + ((TextArea) component).setRows(integer(element, "rows", ((TextArea) component).getRows())); + ((TextArea) component).setMaxSize(integer(element, "maxSize", ((TextArea) component).getMaxSize())); + ((TextArea) component).setEditable(!"false".equals(value(element, "editable", "true"))); + ((TextArea) component).setGrowByContent(!"false".equals(value(element, "growByContent", "true"))); + ((TextArea) component).setConstraint(constraint(value(element, "constraint", "ANY"))); + } + if (component instanceof Container) { + ((Container) component).setScrollableX("true".equals(value(element, "scrollableX", "false"))); + ((Container) component).setScrollableY("true".equals(value(element, "scrollableY", "false"))); + ((Container) component).setTensileDragEnabled(false); + ((Container) component).setAlwaysTensile(false); + ((Container) component).setSmoothScrolling(false); + ((Container) component).setScrollVisible(true); + } + if (component instanceof Slider) { + ((Slider) component).setMinValue(integer(element, "minValue", ((Slider) component).getMinValue())); + ((Slider) component).setMaxValue(integer(element, "maxValue", ((Slider) component).getMaxValue())); + ((Slider) component).setProgress(integer(element, "progress", ((Slider) component).getProgress())); + ((Slider) component).setEditable("true".equals(value(element, "editable", "false"))); + ((Slider) component).setInfinite("true".equals(value(element, "infinite", "false"))); + } + if (component instanceof Tabs && ((Tabs) component).getTabCount() > 0) { + int selected = Math.max(0, Math.min(((Tabs) component).getTabCount() - 1, integer(element, "selectedIndex", 0))); + ((Tabs) component).setSelectedIndex(selected, false); + ((Tabs) component).setTabPlacement(tabPlacement(value(element, "tabPlacement", "top"))); + } + } + + private static int alignment(String value) { + if ("center".equalsIgnoreCase(value)) return Component.CENTER; + if ("right".equalsIgnoreCase(value)) return Component.RIGHT; + return Component.LEFT; + } + + private static int constraint(String value) { + if ("EMAILADDR".equals(value)) return TextArea.EMAILADDR; + if ("PASSWORD".equals(value)) return TextArea.PASSWORD; + if ("NUMERIC".equals(value)) return TextArea.NUMERIC; + if ("URL".equals(value)) return TextArea.URL; + return TextArea.ANY; + } + + private static int tabPlacement(String value) { + if ("bottom".equalsIgnoreCase(value)) return Component.BOTTOM; + if ("left".equalsIgnoreCase(value)) return Component.LEFT; + if ("right".equalsIgnoreCase(value)) return Component.RIGHT; + return Component.TOP; + } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/DragGuideOverlay.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/DragGuideOverlay.java new file mode 100644 index 00000000000..c758d241aac --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/DragGuideOverlay.java @@ -0,0 +1,515 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.ui; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Graphics; +import com.codename1.ui.layouts.BorderLayout; +import java.util.ArrayList; +import java.util.List; + +/** + * Paints insertion and constraint geometry above the visual designer while dragging. + * + *

This is a glass pane: it draws over the whole canvas area and must never be hit by a pointer. + * It is layered at inset zero on top of the canvas host, so ordinary hit testing picked it for + * every press in that area -- including presses on the code editor, which lives inside the canvas + * host once a source or CSS editor is open. The editor therefore never took focus and appeared to + * ignore the keyboard entirely. + */ +public final class DragGuideOverlay extends Component { + /** + * Never claims a pointer position. Hit testing walks children by containment, so returning + * false here keeps the overlay purely decorative and lets presses reach whatever it covers. + */ + @Override + public boolean contains(int x, int y) { + return false; + } + + private String layout = "BoxLayout"; + private String axis = "Y"; + private String constraint; + private boolean valid = true; + private int pointerX; + private int pointerY; + private int parentX; + private int parentY; + private int parentW; + private int parentH; + private int targetX; + private int targetY; + private int targetW; + private int targetH; + private int sourceW; + private int sourceH; + private Component guideParent; + private Component guideSource; + private int snappedX; + private int snappedY; + private String snapDescription; + private boolean guideVisible; + private boolean resizeGuide; + private final List selections = new ArrayList<>(); + private Component primarySelection; + private int resizeX; + private int resizeY; + private int resizeW; + private int resizeH; + private List simulationItems = new ArrayList<>(); + private List simulationLinks = new ArrayList<>(); + private String simulationSummary; + + /** One component in the non-mutating layout simulation painted over the real designer. */ + public static final class GlassItem { + public final String name; + public final int oldX, oldY, oldW, oldH; + public final int newX, newY, newW, newH; + public final boolean active; + + public GlassItem(String name, int oldX, int oldY, int oldW, int oldH, + int newX, int newY, int newW, int newH, boolean active) { + this.name = name; + this.oldX = oldX; this.oldY = oldY; this.oldW = oldW; this.oldH = oldH; + this.newX = newX; this.newY = newY; this.newW = newW; this.newH = newH; + this.active = active; + } + + public boolean changed() { + return oldX != newX || oldY != newY || oldW != newW || oldH != newH; + } + } + + /** A durable constraint/reference edge in the simulated document. */ + public static final class DependencyLink { + public final String from; + public final String to; + public final int fromX, fromY, toX, toY; + public final boolean detached; + + public DependencyLink(String from, String to, int fromX, int fromY, int toX, int toY) { + this(from, to, fromX, fromY, toX, toY, false); + } + + public DependencyLink(String from, String to, int fromX, int fromY, int toX, int toY, + boolean detached) { + this.from = from; this.to = to; + this.fromX = fromX; this.fromY = fromY; this.toX = toX; this.toY = toY; + this.detached = detached; + } + } + + public DragGuideOverlay() { + // setIgnorePointerEvents governs event delivery, not hit testing: the overlay was still + // the component found at a pointer position, which is what decides focus. contains() above + // is what actually keeps it out of the way. + setIgnorePointerEvents(true); + setVisible(false); + } + + public void showGuide(String layout, String axis, String constraint, boolean valid, + Component parent, Component target, Component source, int absoluteX, int absoluteY, + int absoluteSnapX, int absoluteSnapY, int plannedWidth, int plannedHeight, String snapDescription) { + this.layout = layout == null ? "BoxLayout" : layout; + this.axis = axis == null ? "Y" : axis; + this.constraint = constraint; + this.valid = valid; + this.guideParent = parent; + this.guideSource = source; + int ox = paintOriginX(); + int oy = paintOriginY(); + pointerX = absoluteX - ox; + pointerY = absoluteY - oy; + snappedX = absoluteSnapX - ox; + snappedY = absoluteSnapY - oy; + this.snapDescription = snapDescription; + int paddingLeft = parent.getStyle().getPaddingLeftNoRTL(); + int paddingRight = parent.getStyle().getPaddingRightNoRTL(); + int paddingTop = parent.getStyle().getPaddingTop(); + int paddingBottom = parent.getStyle().getPaddingBottom(); + parentX = parent.getAbsoluteX() + paddingLeft - ox; + parentY = parent.getAbsoluteY() + paddingTop - oy; + parentW = Math.max(1, parent.getWidth() - paddingLeft - paddingRight); + parentH = Math.max(1, parent.getHeight() - paddingTop - paddingBottom); + Component guideTarget = target == null ? parent : target; + targetX = guideTarget.getAbsoluteX() - ox; + targetY = guideTarget.getAbsoluteY() - oy; + targetW = guideTarget.getWidth(); + targetH = guideTarget.getHeight(); + sourceW = plannedWidth > 0 ? plannedWidth : source == null || source.getWidth() < 1 ? Math.max(48, targetW) : source.getWidth(); + sourceH = plannedHeight > 0 ? plannedHeight : source == null || source.getHeight() < 1 ? Math.max(32, targetH) : source.getHeight(); + guideVisible = true; + resizeGuide = false; + setVisible(true); + repaint(); + } + + public void showResize(Container parent, Component source, int absoluteX, int absoluteY, + int width, int height, String description) { + guideParent = parent; + guideSource = source; + int ox = paintOriginX(); + int oy = paintOriginY(); + resizeX = absoluteX - ox; + resizeY = absoluteY - oy; + resizeW = width; + resizeH = height; + snapDescription = description; + guideVisible = true; + resizeGuide = true; + setVisible(true); + repaint(); + } + + public void showSelection(Component component) { + selections.clear(); + if (component != null) selections.add(component); + primarySelection = component; + setVisible(component != null || guideVisible); + repaint(); + } + + public void showSelections(List components, Component primary) { + selections.clear(); + if (components != null) selections.addAll(components); + primarySelection = primary != null && selections.contains(primary) + ? primary : selections.isEmpty() ? null : selections.get(selections.size() - 1); + setVisible(!selections.isEmpty() || guideVisible || !simulationItems.isEmpty()); + repaint(); + } + + public void showSimulation(List items, List links, String summary) { + simulationItems = items == null ? new ArrayList<>() : new ArrayList<>(items); + simulationLinks = links == null ? new ArrayList<>() : new ArrayList<>(links); + simulationSummary = summary; + setVisible(!selections.isEmpty() || guideVisible || !simulationItems.isEmpty()); + repaint(); + } + + public void clearSimulation() { + simulationItems.clear(); + simulationLinks.clear(); + simulationSummary = null; + setVisible(!selections.isEmpty() || guideVisible); + repaint(); + } + + public void clearSelection() { + selections.clear(); + primarySelection = null; + setVisible(guideVisible); + repaint(); + } + + /** Absolute pixels occupied by the selection rectangle as it will be painted. */ + public int[] selectionPaintBounds() { + int[] local = selectionPaintLocalBounds(); + if (local == null) return null; + return new int[]{paintOriginX() + local[0], paintOriginY() + local[1], local[2], local[3]}; + } + + /** Coordinates passed to Graphics, whose origin is the overlay's parent—not the overlay itself. */ + public int[] selectionPaintLocalBounds() { + if (primarySelection == null || primarySelection.getParent() == null) return null; + return new int[]{primarySelection.getAbsoluteX() - paintOriginX(), primarySelection.getAbsoluteY() - paintOriginY(), + primarySelection.getWidth(), primarySelection.getHeight()}; + } + + public List selectionPaintBoundsList() { + List result = new ArrayList<>(); + for (Component selection : selections) { + if (selection == null || selection.getParent() == null) continue; + result.add(new int[]{selection.getAbsoluteX(), selection.getAbsoluteY(), + selection.getWidth(), selection.getHeight()}); + } + return result; + } + + private int paintOriginX() { return getParent() == null ? 0 : getParent().getAbsoluteX(); } + private int paintOriginY() { return getParent() == null ? 0 : getParent().getAbsoluteY(); } + + public void hideGuide() { + guideVisible = false; + resizeGuide = false; + simulationItems.clear(); + simulationLinks.clear(); + simulationSummary = null; + setVisible(!selections.isEmpty()); + repaint(); + } + + @Override + public void paint(Graphics g) { + if (!isVisible()) return; + super.paint(g); + int oldAlpha = g.getAlpha(); + paintSelection(g); + paintSimulation(g); + if (!guideVisible) { + g.setAlpha(oldAlpha); + return; + } + if (resizeGuide) { + g.setColor(0x20b486); + g.setAlpha(28); + g.fillRect(resizeX, resizeY, resizeW, resizeH); + g.setAlpha(255); + g.drawRect(resizeX, resizeY, resizeW, resizeH); + paintHandles(g, resizeX, resizeY, resizeW, resizeH, 0x20b486); + if (snapDescription != null) g.drawString(snapDescription, resizeX + 8, resizeY + 16); + g.setAlpha(oldAlpha); + return; + } + g.setColor(valid ? 0x20b486 : 0xd64545); + if ("LayeredLayout".equals(layout)) { + int x = clamp(snappedX, parentX, parentX + Math.max(0, parentW - sourceW)); + int y = clamp(snappedY, parentY, parentY + Math.max(0, parentH - sourceH)); + int w = Math.min(sourceW, parentW); + int h = Math.min(sourceH, parentH); + g.setAlpha(24); + g.fillRect(x, y, w, h); + g.setAlpha(190); + g.drawRect(x, y, w, h); + paintLayeredRelationships(g, x, y, w, h); + if (snapDescription != null && snapDescription.indexOf("baseline") >= 0 && guideSource != null) { + int baseline = guideSource.getBaseline(w, h); + if (baseline >= 0) { + g.setColor(0xbd45d6); + g.drawLine(parentX, y + baseline, parentX + parentW, y + baseline); + g.drawString("baseline", x + w + 5, y + baseline - 3); + } + } + g.setColor(0x20b486); + g.drawString(snapDescription == null ? "Free position" : snapDescription, x + 8, y + 14); + } else if ("BorderLayout".equals(layout)) { + int[] rect = borderRectangle(); + int x = rect[0], y = rect[1], w = rect[2], h = rect[3]; + g.setAlpha(100); + g.fillRect(x, y, w, h); + g.setAlpha(255); + g.drawRect(x, y, w, h); + g.drawString((constraint == null ? "CENTER" : constraint.toUpperCase()) + + (valid ? "" : " — OCCUPIED"), x + 8, y + 14); + } else if ("GridLayout".equals(layout) || "TableLayout".equals(layout) || "FlowLayout".equals(layout)) { + g.setAlpha(70); + g.fillRect(targetX, targetY, targetW, targetH); + g.setAlpha(255); + g.drawRect(targetX, targetY, targetW, targetH); + g.drawString(pointerX < targetX + targetW / 2 ? "Insert before" : "Insert after", + targetX + 8, targetY + 14); + } else { + g.setAlpha(255); + if ("X".equals(axis)) { + int line = pointerX < targetX + targetW / 2 ? targetX : targetX + targetW; + g.fillRect(line - 2, parentY, 4, parentH); + } else { + int line = pointerY < targetY + targetH / 2 ? targetY : targetY + targetH; + g.fillRect(parentX, line - 2, parentW, 4); + } + } + g.setAlpha(oldAlpha); + } + + private void paintSimulation(Graphics g) { + if (simulationItems.isEmpty()) return; + int ox = paintOriginX(); + int oy = paintOriginY(); + for (DependencyLink link : simulationLinks) { + g.setColor(link.detached ? 0xd64545 : 0x9b6cff); + g.setAlpha(105); + int x1 = link.fromX - ox, y1 = link.fromY - oy; + int x2 = link.toX - ox, y2 = link.toY - oy; + drawArrow(g, x1, y1, x2, y2); + } + for (GlassItem item : simulationItems) { + int oldX = item.oldX - ox, oldY = item.oldY - oy; + int newX = item.newX - ox, newY = item.newY - oy; + int color = item.active ? 0x4f8cff : 0xf29f3d; + if (item.changed()) { + g.setColor(0x7f8b99); + g.setAlpha(60); + g.drawRect(oldX, oldY, item.oldW, item.oldH); + drawArrow(g, oldX + item.oldW / 2, oldY + item.oldH / 2, + newX + item.newW / 2, newY + item.newH / 2); + } + g.setColor(color); + g.setAlpha(item.active ? 28 : 14); + g.fillRect(newX, newY, item.newW, item.newH); + g.setAlpha(item.active ? 210 : 135); + g.drawRect(newX, newY, item.newW, item.newH); + String delta = delta(item); + if (item.active) paintTag(g, item.name + (delta.length() == 0 ? "" : " " + delta), + newX + 4, newY + 3, color); + } + if (simulationSummary != null && simulationSummary.length() > 0) { + paintTag(g, simulationSummary, 12, 12, 0x263445); + } + g.setAlpha(255); + } + + private String delta(GlassItem item) { + String out = ""; + int dx = item.newX - item.oldX, dy = item.newY - item.oldY; + int dw = item.newW - item.oldW, dh = item.newH - item.oldH; + if (dx != 0 || dy != 0) out = "move " + signed(dx) + "," + signed(dy); + if (dw != 0 || dh != 0) out += (out.length() == 0 ? "" : " • ") + "size " + signed(dw) + "," + signed(dh); + return out; + } + + private String signed(int value) { return value > 0 ? "+" + value : String.valueOf(value); } + + private void paintTag(Graphics g, String text, int x, int y, int background) { + int width = Math.min(Math.max(20, g.getFont().stringWidth(text) + 10), Math.max(20, getWidth() - x - 4)); + int height = g.getFont().getHeight() + 5; + g.setColor(background); + g.setAlpha(235); + g.fillRect(x, y, width, height); + g.setColor(0xffffff); + g.setAlpha(255); + g.drawString(text, x + 5, y + 2); + } + + private void drawArrow(Graphics g, int x1, int y1, int x2, int y2) { + g.drawLine(x1, y1, x2, y2); + int dx = x2 - x1, dy = y2 - y1; + int scale = Math.max(1, Math.max(Math.abs(dx), Math.abs(dy))); + int baseX = x2 - dx * 9 / scale; + int baseY = y2 - dy * 9 / scale; + int wingX = -dy * 4 / scale; + int wingY = dx * 4 / scale; + g.drawLine(x2, y2, baseX + wingX, baseY + wingY); + g.drawLine(x2, y2, baseX - wingX, baseY - wingY); + } + + private void paintSelection(Graphics g) { + for (Component selection : selections) { + if (selection == null || selection.getParent() == null) continue; + int x = selection.getAbsoluteX() - paintOriginX(); + int y = selection.getAbsoluteY() - paintOriginY(); + int w = selection.getWidth(); + int h = selection.getHeight(); + boolean primary = selection == primarySelection; + g.setAlpha(primary ? 220 : 145); + g.setColor(0x4f8cff); + g.drawRect(x, y, w, h); + paintSelectionHandles(g, x, y, w, h, 0x4f8cff, primary); + } + } + + private void paintSelectionHandles(Graphics g, int x, int y, int w, int h, int color, + boolean primary) { + int handle = primary ? 11 : 9; + int half = handle / 2; + int[] xs = {x, x + w / 2, x + w}; + int[] ys = {y, y + h / 2, y + h}; + for (int xi = 0; xi < xs.length; xi++) { + for (int yi = 0; yi < ys.length; yi++) { + if (xi == 1 && yi == 1) continue; + int hx = xs[xi] - half; + int hy = ys[yi] - half; + if (primary) { + g.setColor(color); + g.setAlpha(245); + g.fillRect(hx, hy, handle, handle); + } else { + g.setColor(0xffffff); + g.setAlpha(235); + g.fillRect(hx, hy, handle, handle); + g.setColor(color); + g.setAlpha(220); + g.drawRect(hx, hy, handle, handle); + g.drawRect(hx + 1, hy + 1, handle - 2, handle - 2); + } + } + } + } + + private void paintHandles(Graphics g, int x, int y, int w, int h, int color) { + int handle = 8; + int half = handle / 2; + int[] xs = {x, x + w / 2, x + w}; + int[] ys = {y, y + h / 2, y + h}; + g.setColor(color); + for (int xi = 0; xi < xs.length; xi++) { + for (int yi = 0; yi < ys.length; yi++) { + if (xi == 1 && yi == 1) continue; + g.fillRect(xs[xi] - half, ys[yi] - half, handle, handle); + } + } + } + + private int[] borderRectangle() { + if (!valid && targetW > 0 && targetH > 0) return new int[]{targetX, targetY, targetW, targetH}; + int top = parentY; + int bottom = parentY + parentH; + int left = parentX; + int right = parentX + parentW; + if (guideParent instanceof Container && ((Container) guideParent).getLayout() instanceof BorderLayout) { + BorderLayout border = (BorderLayout) ((Container) guideParent).getLayout(); + Component north = border.getNorth(); + Component south = border.getSouth(); + Component west = border.getWest(); + Component east = border.getEast(); + if (north != null && north != guideSource) top = north.getAbsoluteY() - paintOriginY() + north.getHeight(); + if (south != null && south != guideSource) bottom = south.getAbsoluteY() - paintOriginY(); + if (west != null && west != guideSource) left = west.getAbsoluteX() - paintOriginX() + west.getWidth(); + if (east != null && east != guideSource) right = east.getAbsoluteX() - paintOriginX(); + } + if ("North".equals(constraint)) return new int[]{parentX, parentY, parentW, Math.min(parentH, sourceH)}; + if ("South".equals(constraint)) return new int[]{parentX, Math.max(parentY, parentY + parentH - sourceH), parentW, Math.min(parentH, sourceH)}; + if ("West".equals(constraint)) return new int[]{parentX, top, Math.min(parentW, sourceW), Math.max(1, bottom - top)}; + if ("East".equals(constraint)) return new int[]{Math.max(parentX, parentX + parentW - sourceW), top, Math.min(parentW, sourceW), Math.max(1, bottom - top)}; + return new int[]{left, top, Math.max(1, right - left), Math.max(1, bottom - top)}; + } + + private void paintLayeredRelationships(Graphics g, int x, int y, int w, int h) { + if (!(guideParent instanceof Container)) return; + int threshold = 2; + g.setColor(0x4f8cff); + g.setAlpha(125); + for (int i = 0; i < ((Container) guideParent).getComponentCount(); i++) { + Component sibling = ((Container) guideParent).getComponentAt(i); + if (sibling == guideSource || sibling.getClientProperty("gui.element") == null) continue; + int sx = sibling.getAbsoluteX() - paintOriginX(); + int sy = sibling.getAbsoluteY() - paintOriginY(); + int sw = sibling.getWidth(); + int sh = sibling.getHeight(); + if (Math.abs(x - sx) <= threshold || Math.abs(x + w - sx - sw) <= threshold + || Math.abs(x + w / 2 - sx - sw / 2) <= threshold) { + int line = Math.abs(x + w / 2 - sx - sw / 2) <= threshold ? x + w / 2 + : Math.abs(x - sx) <= threshold ? x : x + w; + g.drawLine(line, parentY, line, parentY + parentH); + } + if (Math.abs(y - sy) <= threshold || Math.abs(y + h - sy - sh) <= threshold + || Math.abs(y + h / 2 - sy - sh / 2) <= threshold) { + int line = Math.abs(y + h / 2 - sy - sh / 2) <= threshold ? y + h / 2 + : Math.abs(y - sy) <= threshold ? y : y + h; + g.drawLine(parentX, line, parentX + parentW, line); + } + } + } + + private int clamp(int value, int min, int max) { return Math.max(min, Math.min(max, value)); } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/GuidedLayoutSupport.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/GuidedLayoutSupport.java new file mode 100644 index 00000000000..ada6af1bf0d --- /dev/null +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/ui/GuidedLayoutSupport.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.ui; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.xml.Element; +import java.util.ArrayList; +import java.util.List; + +/** + * Applies the persistent, name-based constraints used by the Guided Layout designer. + * LayeredLayout's native reference serialization is index based; names remain stable when + * components are reordered, so the builder stores names and resolves them at preview/runtime + * generation time. + */ +public final class GuidedLayoutSupport { + public static final String PREFERRED = "preferred"; + public static final String FIXED = "fixed"; + public static final String FILL = "fill"; + public static final String MATCH = "match"; + + private GuidedLayoutSupport() { } + + public static void apply(Element parentElement, Container parent) { + if (!(parent.getLayout() instanceof LayeredLayout)) return; + LayeredLayout layered = (LayeredLayout) parent.getLayout(); + List elements = componentChildren(parentElement); + for (int i = 0; i < elements.size() && i < parent.getComponentCount(); i++) { + Element element = elements.get(i); + Component component = parent.getComponentAt(i); + layered.setInsets(component, value(element, "layeredInsets", "auto auto auto auto")); + String references = value(element, "guidedReferences", "-|-|-|-"); + String[] names = four(references, "-"); + Component[] refs = new Component[4]; + for (int side = 0; side < refs.length; side++) refs[side] = namedChild(parent, names[side]); + try { + layered.setReferenceComponents(component, refs); + layered.setReferencePositions(component, value(element, "guidedReferencePositions", "0 0 0 0")); + layered.setPercentInsetAnchorHorizontal(component, decimal(element, "guidedHorizontalAnchor", 0f)); + layered.setPercentInsetAnchorVertical(component, decimal(element, "guidedVerticalAnchor", 0f)); + } catch (IllegalArgumentException circularReference) { + // A malformed hand-edited file must remain designable. Ignore only its references; + // the inspector can then be used to repair the relationship. + layered.setReferenceComponents(component, new Component[]{null, null, null, null}); + } + } + } + + public static String horizontalPolicy(Element element) { + String explicit = element.getAttribute("guidedHorizontalSize"); + if (validPolicy(explicit)) return explicit; + String[] insets = insetValues(element); + return !"auto".equals(insets[1]) && !"auto".equals(insets[3]) ? FIXED : PREFERRED; + } + + public static String verticalPolicy(Element element) { + String explicit = element.getAttribute("guidedVerticalSize"); + if (validPolicy(explicit)) return explicit; + String[] insets = insetValues(element); + return !"auto".equals(insets[0]) && !"auto".equals(insets[2]) ? FIXED : PREFERRED; + } + + public static String[] insetValues(Element element) { + return cssFour(value(element, "layeredInsets", "auto auto auto auto"), "auto"); + } + + public static String[] referenceNames(Element element) { + return four(value(element, "guidedReferences", "-|-|-|-"), "-"); + } + + public static String[] referencePositions(Element element) { + return cssFour(value(element, "guidedReferencePositions", "0 0 0 0"), "0"); + } + + public static String joinInsets(String top, String right, String bottom, String left) { + return top + " " + right + " " + bottom + " " + left; + } + + public static String joinReferences(String top, String right, String bottom, String left) { + return cleanReference(top) + "|" + cleanReference(right) + "|" + + cleanReference(bottom) + "|" + cleanReference(left); + } + + public static String joinPositions(String top, String right, String bottom, String left) { + return top + " " + right + " " + bottom + " " + left; + } + + private static Component namedChild(Container parent, String name) { + if (name == null || name.length() == 0 || "-".equals(name)) return null; + for (int i = 0; i < parent.getComponentCount(); i++) { + Component child = parent.getComponentAt(i); + Object value = child.getClientProperty("gui.element"); + if (value instanceof Element && name.equals(((Element) value).getAttribute("name"))) return child; + } + return null; + } + + private static List componentChildren(Element parent) { + List out = new ArrayList<>(); + for (int i = 0; i < parent.getNumChildren(); i++) { + Object child = parent.getChildAt(i); + if (child instanceof Element && "component".equals(((Element) child).getTagName())) out.add(((Element) child)); + } + return out; + } + + private static String[] four(String value, String fallback) { + String[] split = value == null ? new String[0] : value.split("\\|", -1); + String[] out = {fallback, fallback, fallback, fallback}; + for (int i = 0; i < out.length && i < split.length; i++) { + if (split[i] != null && split[i].trim().length() > 0) out[i] = split[i].trim(); + } + return out; + } + + private static String[] cssFour(String value, String fallback) { + String[] split = value == null ? new String[0] : value.trim().split("\\s+"); + if (split.length == 1) return new String[]{split[0], split[0], split[0], split[0]}; + if (split.length == 2) return new String[]{split[0], split[1], split[0], split[1]}; + if (split.length == 3) return new String[]{split[0], split[1], split[2], split[1]}; + if (split.length >= 4) return new String[]{split[0], split[1], split[2], split[3]}; + return new String[]{fallback, fallback, fallback, fallback}; + } + + private static boolean validPolicy(String value) { + return PREFERRED.equals(value) || FIXED.equals(value) || FILL.equals(value) || MATCH.equals(value); + } + + private static String cleanReference(String value) { + return value == null || value.trim().length() == 0 ? "-" : value.trim(); + } + + private static String value(Element element, String name, String fallback) { + String value = element.getAttribute(name); + return value == null || value.length() == 0 ? fallback : value; + } + + private static float decimal(Element element, String name, float fallback) { + try { return Float.parseFloat(value(element, name, String.valueOf(fallback))); } + catch (NumberFormatException ex) { return fallback; } + } +} diff --git a/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/model/GuiDocumentTest.java b/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/model/GuiDocumentTest.java new file mode 100644 index 00000000000..235c6ad6bce --- /dev/null +++ b/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/model/GuiDocumentTest.java @@ -0,0 +1,422 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.model; + +import com.codename1.xml.Element; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class GuiDocumentTest { + @Test + void undoRestoresTheSavedStateAndRedoRestoresTheModifiedState() { + GuiDocument document = GuiDocument.parse("Form.gui", + ""); + document.select(document.components().get(1)); + document.setAttribute("text", "After"); + assertTrue(document.isModified()); + + assertTrue(document.undo()); + assertFalse(document.isModified(), "undoing the first edit must return to the saved state"); + assertEquals("Before", document.components().get(1).getAttribute("text")); + + assertTrue(document.redo()); + assertTrue(document.isModified()); + assertEquals("After", document.components().get(1).getAttribute("text")); + } + + @Test + void preservesExistingXmlAndSupportsEditing() { + GuiDocument document = GuiDocument.parse("Login.gui", + "" + + "" + + ""); + + assertEquals(2, document.components().size()); + document.select(document.components().get(1)); + document.setAttribute("uiid", "LoginTitle"); + document.addComponent("Button"); + + String saved = document.toXml(); + assertTrue(saved.contains("custom=\"keep-me\"")); + assertTrue(saved.contains("uiid=\"LoginTitle\"")); + assertTrue(saved.contains("type=\"Button\"")); + assertTrue(document.isModified()); + } + + @Test + void createsUniqueComponentNames() { + GuiDocument document = GuiDocument.parse("Form.gui", ""); + assertEquals("button1", document.addComponent("Button").getAttribute("name")); + document.select(document.root()); + assertEquals("button2", document.addComponent("Button").getAttribute("name")); + } + + @Test + void newContainersUseGuidedLayoutByDefault() { + GuiDocument document = GuiDocument.parse("Form.gui", ""); + Element container = document.addComponent("Container"); + assertEquals("LayeredLayout", container.getAttribute("layout")); + } + + @Test + void doesNotDeleteRoot() { + GuiDocument document = GuiDocument.parse("Form.gui", ""); + assertFalse(document.deleteSelected()); + } + + @Test + void copiesAndPastesWithAUniqueName() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + ""); + + document.select(document.components().get(1)); + String clipboard = document.copySelectedXml(); + document.select(document.root()); + Element pasted = document.pasteXml(clipboard); + + assertNotNull(pasted); + assertEquals("button2", pasted.getAttribute("name")); + assertEquals("Save", pasted.getAttribute("text")); + assertEquals(3, document.components().size()); + } + + /** + * Pasted XML carries the cell it was copied from, so pasting a table child back into its own + * table used to stack the copy on top of the original in the preview and the generated source. + */ + @Test + void aPastedTableChildTakesAFreeCellInsteadOfTheOneItWasCopiedFrom() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + ""); + + Element original = named(document, "cell"); + document.select(original); + String clipboard = document.copySelectedXml(); + document.select(named(document, "grid")); + Element pasted = document.pasteXml(clipboard); + + assertNotNull(pasted); + assertNotEquals( + original.getAttribute("tableRow") + ":" + original.getAttribute("tableColumn"), + pasted.getAttribute("tableRow") + ":" + pasted.getAttribute("tableColumn"), + "the copy landed in the cell it was copied from"); + } + + @Test + void pastedContainerChildrenAlsoGetUniqueNamesAndKeepInternalRelationships() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + "" + + ""); + + document.select(document.components().get(1)); + String clipboard = document.copySelectedXml(); + document.select(document.root()); + Element pasted = document.pasteXml(clipboard); + + assertNotNull(pasted); + assertEquals("card1", pasted.getAttribute("name")); + Element pastedAction = named(document, "action1"); + assertNotNull(named(document, "caption1")); + assertNotNull(pastedAction); + assertEquals("caption1|-|-|caption1", pastedAction.getAttribute("guidedReferences"), + "a pasted relationship must follow the pasted copy, not the original"); + assertEquals("caption1", pastedAction.getAttribute("guidedMatchWidth")); + assertEquals("caption|-|-|caption", named(document, "action").getAttribute("guidedReferences"), + "the original relationship must be untouched"); + } + + @Test + void renamingAComponentKeepsNamesUniqueAndRepointsEveryRelationship() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + ""); + Element caption = document.components().get(1); + Element action = document.components().get(2); + + document.select(caption); + assertEquals("title", document.renameSelected("title")); + assertEquals("title|-|-|title", action.getAttribute("guidedReferences")); + assertEquals("title", action.getAttribute("guidedMatchWidth")); + assertEquals("title", action.getAttribute("guidedReferenceTarget")); + + document.select(action); + assertEquals("title1", document.renameSelected("title"), + "a duplicate name would break name-based references and generated fields"); + + assertTrue(document.undo()); + assertEquals("action", document.components().get(2).getAttribute("name")); + assertTrue(document.undo()); + assertEquals("caption", document.components().get(1).getAttribute("name")); + assertEquals("caption|-|-|caption", document.components().get(2).getAttribute("guidedReferences"), + "a rename and its reference updates must undo together"); + } + + @Test + void deletingAReferencedComponentLeavesNoDanglingRelationship() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + ""); + + document.select(document.components().get(1)); + assertTrue(document.deleteSelected()); + Element action = document.components().get(1); + assertEquals("-|-|-|-", action.getAttribute("guidedReferences")); + assertNull(action.getAttribute("guidedMatchWidth")); + assertNull(action.getAttribute("guidedReferenceTarget")); + assertEquals("preferred", action.getAttribute("guidedHorizontalSize"), + "a match policy without a target must fall back to the component's own size"); + + assertTrue(document.undo()); + assertEquals("caption|-|-|caption", document.components().get(2).getAttribute("guidedReferences")); + } + + @Test + void reordersSiblingsAndMovesComponentsIntoContainers() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Element group = document.components().get(3); + + document.select(second); + assertTrue(document.moveSelectedTo(first)); + assertSame(second, document.root().getChildAt(0)); + + document.select(first); + assertTrue(document.moveSelectedTo(group)); + assertSame(first, group.getChildAt(0)); + assertEquals(2, document.root().getNumChildren()); + } + + @Test + void dropsSiblingsBeforeOrAfterTheIndicatedComponent() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Element third = document.components().get(3); + + document.select(first); + assertTrue(document.moveSelectedTo(second, true)); + assertSame(second, document.root().getChildAt(0)); + assertSame(first, document.root().getChildAt(1)); + assertSame(third, document.root().getChildAt(2)); + + document.select(third); + assertTrue(document.moveSelectedTo(second, false)); + assertSame(third, document.root().getChildAt(0)); + assertSame(second, document.root().getChildAt(1)); + assertSame(first, document.root().getChildAt(2)); + } + + @Test + void movesComponentsToAnExactParentSlotForCrossContainerSwaps() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + ""); + Element left = document.components().get(1); + Element a = document.components().get(2); + Element b = document.components().get(3); + Element right = document.components().get(4); + Element c = document.components().get(5); + + document.select(c); + assertTrue(document.moveSelectedToParent(left, 1)); + assertSame(a, left.getChildAt(0)); + assertSame(c, left.getChildAt(1)); + assertSame(b, left.getChildAt(2)); + assertEquals(1, document.componentIndex(left, c)); + assertEquals(0, right.getNumChildren()); + } + + @Test + void preventsMovingAContainerIntoItsOwnDescendant() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + ""); + Element outer = document.components().get(1); + Element inner = document.components().get(2); + + document.select(outer); + assertFalse(document.moveSelectedTo(inner)); + } + + @Test + void exposesDefaultUiidsAndTypeAwareParentLayout() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + ""); + Element button = document.components().get(1); + + assertEquals("Button", document.effectiveUiid(button)); + assertEquals("LayeredLayout", document.parentLayout(button)); + button.setAttribute("uiid", "PrimaryAction"); + assertEquals("PrimaryAction", document.effectiveUiid(button)); + } + + @Test + void normalizesMissingAndDuplicateBorderLayoutConstraints() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Element third = document.components().get(3); + + assertEquals("Center", GuiDocument.effectiveBorderConstraint(document.root(), first)); + assertEquals("North", GuiDocument.effectiveBorderConstraint(document.root(), second)); + assertEquals("West", GuiDocument.effectiveBorderConstraint(document.root(), third)); + assertSame(second, GuiDocument.childAtBorderConstraint(document.root(), "North", null)); + assertNull(GuiDocument.childAtBorderConstraint(document.root(), "South", null)); + } + + @Test + void editsToolbarCommandsWithoutMixingThemIntoComponents() { + GuiDocument document = GuiDocument.parse("Form.gui", ""); + Element command = document.addCommand(); + document.setCommandAttribute(command, "name", "Save"); + document.setCommandAttribute(command, "placement", "left"); + + assertEquals(1, document.commands().size()); + assertEquals("Save", document.commands().get(0).getAttribute("name")); + assertEquals(1, document.components().size()); + assertTrue(document.toXml().contains("" + + "" + + ""); + Element card = document.components().get(1); + document.select(card); + document.setAttribute("layeredInsets", "20% 60% 60% 20%"); + document.setAttribute("layoutConstraint", "Center"); + + assertEquals("20% 60% 60% 20%", card.getAttribute("layeredInsets")); + assertEquals("Center", card.getAttribute("layoutConstraint")); + String xml = document.toXml(); + assertEquals(1, occurrences(xml.toLowerCase(), "layeredinsets=")); + assertEquals(1, occurrences(xml.toLowerCase(), "layoutconstraint=")); + GuiDocument reparsed = GuiDocument.parse("Form.gui", xml); + assertEquals("20% 60% 60% 20%", reparsed.components().get(1).getAttribute("layeredInsets")); + assertEquals("Center", reparsed.components().get(1).getAttribute("layoutConstraint")); + } + + @Test + void undoRedoTreatsACompoundPlacementAsOneEditAndRestoresNestedHierarchy() { + GuiDocument document = GuiDocument.parse("Form.gui", + "" + + "" + + ""); + Element button = document.components().get(3); + Element destination = document.components().get(4); + document.select(button); + document.beginTransaction(); + assertTrue(document.moveSelectedToParent(destination, 0)); + document.setAttribute("layoutConstraint", "Center"); + document.endTransaction(); + assertSame(destination, document.parentOf(document.selected())); + assertTrue(document.canUndo()); + + assertTrue(document.undo()); + Element restoredButton = document.selected(); + assertEquals("moveMe", restoredButton.getAttribute("name")); + assertEquals("inner", document.parentOf(restoredButton).getAttribute("name")); + assertFalse(document.canUndo(), "compound placement should create one undo entry"); + assertTrue(document.canRedo()); + + assertTrue(document.redo()); + assertEquals("destination", document.parentOf(document.selected()).getAttribute("name")); + assertEquals("Center", document.selected().getAttribute("layoutConstraint")); + } + + @Test + void undoingAcrossASaveLeavesTheDocumentDirty() { + GuiDocument document = GuiDocument.parse("path.gui", + ""); + document.addComponent("Button"); + document.markSaved(); + assertFalse(document.isModified(), "a document that was just written matches the file"); + + // Undo takes the form back to a state that predates the save, so it no longer matches what + // is on disk even though the snapshot it came from was captured while the form was clean. + assertTrue(document.undo()); + assertTrue(document.isModified(), + "undoing past the save point must leave the document dirty, or the next form switch" + + " discards the undone state without a prompt"); + + assertTrue(document.redo()); + assertFalse(document.isModified(), "redoing back to the saved content is clean again"); + } + + private static Element named(GuiDocument document, String name) { + for (Element element : document.components()) { + if (name.equals(element.getAttribute("name"))) return element; + } + return null; + } + + private static int occurrences(String text, String needle) { + int count = 0; + for (int at = 0; (at = text.indexOf(needle, at)) >= 0; at += needle.length()) count++; + return count; + } +} diff --git a/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/project/ProjectBindingTest.java b/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/project/ProjectBindingTest.java new file mode 100644 index 00000000000..f5e6583d6df --- /dev/null +++ b/scripts/guibuilder/common/src/test/java/com/codename1/guibuilder/project/ProjectBindingTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder.project; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ProjectBindingTest { + @Test + void parsesModernProjectBinding() { + ProjectBinding binding = ProjectBinding.parse("# binding\n" + + "projectDir=/tmp/app/common\n" + + "guiDir=/tmp/app/common/src/main/guibuilder\n" + + "sourceDir=/tmp/app/common/src/main/java\n" + + "cssFile=/tmp/app/common/src/main/css/theme.css\n" + + "initialForm=com.example.Login\n"); + assertTrue(binding.isValid()); + assertEquals("com.example.Login", binding.initialForm()); + assertEquals("/tmp/app/common/src/main/css/theme.css", binding.cssFile()); + } +} diff --git a/scripts/guibuilder/demo-project/src/main/css/theme.css b/scripts/guibuilder/demo-project/src/main/css/theme.css new file mode 100644 index 00000000000..adb839c8da7 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/css/theme.css @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +Form { + background-color: #ffffff; + color: #172033; + padding: 3mm; +} + +Title { + background-color: #17233b; + color: #ffffff; + font-family: "native:MainBold"; + padding: 2mm; +} + +Label, SpanLabel { + background-color: transparent; + color: #26324a; + padding: 2mm; +} + +CheckBox, RadioButton { + background-color: transparent; + color: #26324a; +} + +Button { + background-color: white; + color: #2459b8; + border: 1px solid #315fce; + border-radius: 2mm; + padding: 2mm 4mm; + margin: 1mm; +} + +@media (prefers-color-scheme: dark) { + Form { background-color: black; color: #f5f8ff; } + Title { background-color: #071b4d; color: #f5f8ff; } + Label, SpanLabel { background-color: transparent; color: #f5f8ff; } + CheckBox, RadioButton { background-color: transparent; color: #f5f8ff; } + Button { background-color: #163575; color: #f5f8ff; border-color: #5876a9; } + TextField { background-color: #0e2a61; color: #f5f8ff; border-color: #5876a9; } +} + +TextField { + background-color: #f7f9fc; + color: #172033; + border: 1px solid #c8d1e2; + padding: 2mm; + margin: 1mm; +} diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/BorderDropForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/BorderDropForm.gui new file mode 100644 index 00000000000..f98e600efb1 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/BorderDropForm.gui @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/BoxXLayoutForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/BoxXLayoutForm.gui new file mode 100644 index 00000000000..256e5ab21be --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/BoxXLayoutForm.gui @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/DeepNestingForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/DeepNestingForm.gui new file mode 100644 index 00000000000..091cdd4c05c --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/DeepNestingForm.gui @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/DrainAndRefillForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/DrainAndRefillForm.gui new file mode 100644 index 00000000000..3af20ca8f6b --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/DrainAndRefillForm.gui @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/GridLayoutForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/GridLayoutForm.gui new file mode 100644 index 00000000000..e8f4ee1d412 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/GridLayoutForm.gui @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/GuidedLayoutForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/GuidedLayoutForm.gui new file mode 100644 index 00000000000..f390e46881f --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/GuidedLayoutForm.gui @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/LoginForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/LoginForm.gui new file mode 100644 index 00000000000..cc94588a539 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/LoginForm.gui @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/MixedNestedLayoutsForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/MixedNestedLayoutsForm.gui new file mode 100644 index 00000000000..3433827b3f3 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/MixedNestedLayoutsForm.gui @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedGuidedForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedGuidedForm.gui new file mode 100644 index 00000000000..8de0f1b9457 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedGuidedForm.gui @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedLayoutsForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedLayoutsForm.gui new file mode 100644 index 00000000000..e128a24a8b2 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedLayoutsForm.gui @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedTableForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedTableForm.gui new file mode 100644 index 00000000000..a0debd62c46 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/NestedTableForm.gui @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/TableLayoutForm.gui b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/TableLayoutForm.gui new file mode 100644 index 00000000000..618313ef4a6 --- /dev/null +++ b/scripts/guibuilder/demo-project/src/main/guibuilder/com/example/TableLayoutForm.gui @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/scripts/guibuilder/javase/pom.xml b/scripts/guibuilder/javase/pom.xml new file mode 100644 index 00000000000..30e8d6daa33 --- /dev/null +++ b/scripts/guibuilder/javase/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + com.codenameone.guibuildercn1-guibuilder8.0-SNAPSHOT + com.codenameone + codenameone-guibuilder + codenameone-guibuilder + Standalone modern Codename One GUI Builder. + javasejavase + + ${project.basedir}/../common/src/test/java + ${project.basedir}/../../settings/javase/src/desktop/resourcesNativeTheme.res + + com.codenameonecodenameone-maven-plugin${cn1.plugin.version}add-se-sourcesgenerate-sourcesgenerate-javase-sources + org.codehaus.mojobuild-helper-maven-plugin3.4.0add-javase-test-sourcesgenerate-test-sourcesadd-test-source${project.basedir}/src/test/java + + + + com.codenameone.guibuilder${cn1app.name}-common${project.version} + com.codenameone.guibuilder${cn1app.name}-common${project.version}teststest + + com.codenameonecodenameone-core + com.codenameonecodenameone-javase + org.junit.jupiterjunit-jupiter-api5.10.2test + org.junit.jupiterjunit-jupiter-engine5.10.2test + + executable-jarjavase + + org.codehaus.mojoproperties-maven-plugin1.0.0initializeread-project-properties${basedir}/../common/codenameone_settings.properties + com.codenameonecodenameone-maven-plugin${cn1.plugin.version}generate-desktop-app-wrappergenerate-sourcesgenerate-desktop-app-wrapper + org.apache.maven.pluginsmaven-dependency-plugincopy-dependenciesprepare-packagecopy-dependencies${project.build.directory}/libsruntime + org.apache.maven.pluginsmaven-jar-plugintruelibs/com.codename1.guibuilder.CodenameOneGUIBuilderLauncherCodename One GUI BuilderCodename One GUI Builder${project.version} + + + diff --git a/scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderLauncher.java b/scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderLauncher.java new file mode 100644 index 00000000000..11eadebbb1f --- /dev/null +++ b/scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderLauncher.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +public final class CodenameOneGUIBuilderLauncher { + private CodenameOneGUIBuilderLauncher() { } + public static void main(String[] args) throws Exception { + CodenameOneGUIBuilderStub.main(args); + } +} diff --git a/scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderStub.java b/scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderStub.java new file mode 100644 index 00000000000..4341035f5fe --- /dev/null +++ b/scripts/guibuilder/javase/src/desktop/java/com/codename1/guibuilder/CodenameOneGUIBuilderStub.java @@ -0,0 +1,686 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.ui.Display; +import java.awt.Desktop; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.Toolkit; +import java.awt.Robot; +import java.awt.event.KeyEvent; +import java.awt.event.WindowEvent; +import java.awt.event.WindowListener; +import java.awt.image.BufferedImage; +import java.io.File; +import javax.imageio.ImageIO; +import javax.swing.JFrame; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JTextPane; +import javax.swing.JComponent; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.text.JTextComponent; +import javax.swing.undo.UndoManager; + +public final class CodenameOneGUIBuilderStub implements Runnable, WindowListener { + static final String APP_DISPLAY_NAME = "Codename One GUI Builder"; + private static final String APP_STORAGE_NAME = "CodenameOneGUIBuilder"; + private static final int APP_WIDTH = 1440; + private static final int APP_HEIGHT = 900; + private static JFrame frame; + private CodenameOneGUIBuilder app; + + public static void main(String[] args) { + System.setProperty("apple.awt.application.name", APP_DISPLAY_NAME); + System.setProperty("com.apple.mrj.application.apple.menu.about.name", APP_DISPLAY_NAME); + System.setProperty("sun.awt.application.name", APP_DISPLAY_NAME); + System.setProperty("sun.awt.X11.XWMClass", APP_STORAGE_NAME); + JavaSEPort.setNativeTheme("/NativeTheme.res"); + JavaSEPort.blockMonitors(); + JavaSEPort.setAppHomeDir("." + APP_STORAGE_NAME); + JavaSEPort.setExposeFilesystem(true); + JavaSEPort.setTablet(true); + JavaSEPort.setUseNativeInput(true); + JavaSEPort.setShowEDTViolationStacks(false); + JavaSEPort.setShowEDTWarnings(false); + JavaSEPort.setDesktopTitleBarMode("native"); + JavaSEPort.setDesktopInteractiveScrollbars(true); + JavaSEPort.setFontFaces(File.separatorChar == '\\' ? "ArialUnicodeMS" : "Arial", "SansSerif", "Monospaced"); + frame = new JFrame(APP_DISPLAY_NAME); + JavaSEPort.setDefaultPixelMilliRatio(Toolkit.getDefaultToolkit().getScreenResolution() / 25.4 * JavaSEPort.getRetinaScale()); + Display.init(frame.getContentPane()); + Display.getInstance().setProperty("AppName", APP_DISPLAY_NAME); + SwingUtilities.invokeLater(new CodenameOneGUIBuilderStub()); + } + + @Override + public void run() { + frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + frame.addWindowListener(this); + installMenus(); + GraphicsDevice device = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice(); + frame.setLocationByPlatform(true); + frame.setResizable(true); + frame.getContentPane().setPreferredSize(new java.awt.Dimension(APP_WIDTH, APP_HEIGHT)); + frame.getContentPane().setMinimumSize(new java.awt.Dimension(1000, 650)); + frame.pack(); + frame.setVisible(true); + Display.getInstance().callSerially(new Runnable() { + @Override public void run() { + app = new CodenameOneGUIBuilder(); + app.init(this); + app.start(); + SwingUtilities.invokeLater(() -> { + installDesignerPointerBridge(); + installMenus(); + scheduleOpenCss(); + scheduleScreenshot(); + scheduleEditorSelfTest(); + scheduleGuidedLayoutSelfTest(); + scheduleInteractionSelfTest(); + }); + } + }); + } + + private static void scheduleOpenCss() { + if (!Boolean.getBoolean("guibuilder.openCss")) return; + Timer open = new Timer(700, e -> Display.getInstance().callSerially(CodenameOneGUIBuilder::openActiveCssForTest)); + open.setRepeats(false); + open.start(); + } + + private static void installDesignerPointerBridge() { + java.awt.Container canvas = JavaSEPort.instance.getCanvas(); + canvas.addMouseMotionListener(new java.awt.event.MouseMotionAdapter() { + @Override public void mouseDragged(java.awt.event.MouseEvent event) { + int x = (int) Math.round(event.getX() * JavaSEPort.getRetinaScale()); + int y = (int) Math.round(event.getY() * JavaSEPort.getRetinaScale()); + Display.getInstance().callSerially(() -> CodenameOneGUIBuilder.activeDesktopPointerDragged(x, y)); + } + }); + canvas.addMouseListener(new java.awt.event.MouseAdapter() { + @Override public void mouseReleased(java.awt.event.MouseEvent event) { + int x = (int) Math.round(event.getX() * JavaSEPort.getRetinaScale()); + int y = (int) Math.round(event.getY() * JavaSEPort.getRetinaScale()); + Display.getInstance().callSerially(() -> CodenameOneGUIBuilder.activeDesktopPointerReleased(x, y)); + } + }); + } + + private static void scheduleEditorSelfTest() { + if (!Boolean.getBoolean("guibuilder.selfTest")) return; + Timer open = new Timer(900, e -> Display.getInstance().callSerially(CodenameOneGUIBuilder::openActiveCssForTest)); + open.setRepeats(false); + open.start(); + Timer exercise = new Timer(2200, e -> new Thread(() -> { + try { + final JTextPane[] editor = new JTextPane[1]; + SwingUtilities.invokeAndWait(() -> { + frame.setAlwaysOnTop(true); + frame.toFront(); + frame.requestFocus(); + editor[0] = findTextPane(frame.getLayeredPane()); + }); + if (editor[0] == null) throw new AssertionError("No live JTextPane was mounted by theme.css"); + final javax.swing.JScrollPane[] scroll = new javax.swing.JScrollPane[1]; + SwingUtilities.invokeAndWait(() -> { + java.awt.Container parent = editor[0].getParent(); + while (parent != null && !(parent instanceof javax.swing.JScrollPane)) parent = parent.getParent(); + scroll[0] = (javax.swing.JScrollPane) parent; + if (scroll[0] == null || editor[0].getMargin().left < 40 + || !scroll[0].getVerticalScrollBar().isVisible() + || scroll[0].getVerticalScrollBar().getWidth() < 10) { + throw new AssertionError("CSS editor chrome is missing: editorMargin=" + + editor[0].getMargin() + ", verticalBar=" + + (scroll[0] == null ? null : scroll[0].getVerticalScrollBar())); + } + }); + final java.awt.Point[] point = new java.awt.Point[1]; + final int[] initialLength = new int[1]; + SwingUtilities.invokeAndWait(() -> { + initialLength[0] = editor[0].getDocument().getLength(); + editor[0].setCaretPosition(Math.min(8, initialLength[0])); + point[0] = editor[0].getLocationOnScreen(); + }); + Robot robot = new Robot(); + robot.setAutoDelay(45); + robot.mouseMove(frame.getX() + frame.getWidth() / 2, frame.getY() + 10); + robot.mousePress(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.mouseRelease(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.mouseMove(point[0].x + 40, point[0].y + 24); + robot.mousePress(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.mouseRelease(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + SwingUtilities.invokeAndWait(() -> { + frame.toFront(); + frame.requestFocus(); + editor[0].requestFocus(); + }); + long focusDeadline = System.currentTimeMillis() + 1800; + while (!editor[0].isFocusOwner() && System.currentTimeMillis() < focusDeadline) { + robot.mouseMove(point[0].x + 40, point[0].y + 24); + robot.mousePress(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.mouseRelease(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + SwingUtilities.invokeAndWait(() -> { + frame.toFront(); + editor[0].requestFocus(); + }); + Thread.sleep(120); + } + if (!editor[0].isFocusOwner()) throw new AssertionError("macOS did not focus the mounted CSS editor"); + int[] keys = {KeyEvent.VK_Z, KeyEvent.VK_Z, KeyEvent.VK_T, KeyEvent.VK_E, KeyEvent.VK_S, KeyEvent.VK_T}; + for (int key : keys) { robot.keyPress(key); robot.keyRelease(key); } + robot.waitForIdle(); + final String[] text = new String[1]; + final String[] editorState = new String[1]; + SwingUtilities.invokeAndWait(() -> { + text[0] = editor[0].getText(); + java.awt.Component focus = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + editorState[0] = "editable=" + editor[0].isEditable() + ", focusOwner=" + focus + + ", editorBounds=" + editor[0].getBounds() + ", showing=" + editor[0].isShowing() + + ", glassVisible=" + frame.getGlassPane().isVisible(); + }); + if (text[0].length() <= initialLength[0] || text[0].toLowerCase().indexOf("zztest") < 0) { + throw new AssertionError("Physical typing did not mutate the actual theme.css editor: " + editorState[0]); + } + int typedLength = text[0].length(); + int shortcutKey = (menuShortcutMask() + & java.awt.event.InputEvent.META_DOWN_MASK) != 0 ? KeyEvent.VK_META : KeyEvent.VK_CONTROL; + robot.keyPress(shortcutKey); + robot.keyPress(KeyEvent.VK_Z); + robot.keyRelease(KeyEvent.VK_Z); + robot.keyRelease(shortcutKey); + robot.waitForIdle(); + final int[] undoLength = new int[1]; + final JTextPane[] stillMounted = new JTextPane[1]; + SwingUtilities.invokeAndWait(() -> { + undoLength[0] = editor[0].getDocument().getLength(); + stillMounted[0] = findTextPane(frame.getLayeredPane()); + }); + if (stillMounted[0] != editor[0] || undoLength[0] >= typedLength) { + throw new AssertionError("Command-Z escaped the focused CSS editor instead of using its undo buffer"); + } + SwingUtilities.invokeAndWait(() -> editor[0].setText("Button { color: #e11919; }")); + Thread.sleep(450); + java.util.concurrent.atomic.AtomicInteger previewColor = new java.util.concurrent.atomic.AtomicInteger(-1); + Display.getInstance().callSerially(() -> previewColor.set( + CodenameOneGUIBuilder.activePreviewForegroundForTest(null))); + long deadline = System.currentTimeMillis() + 2500; + while (previewColor.get() == -1 && System.currentTimeMillis() < deadline) Thread.sleep(25); + if (previewColor.get() != 0xe11919) { + throw new AssertionError("CSS document changed but live preview color was 0x" + + Integer.toHexString(previewColor.get())); + } + System.out.println("GUIBUILDER_EDITOR_SELF_TEST_PASS"); + if (!"false".equals(System.getProperty("guibuilder.selfTest.exit"))) System.exit(0); + } catch (Throwable failure) { + failure.printStackTrace(); + System.err.println("GUIBUILDER_EDITOR_SELF_TEST_FAIL"); + System.exit(2); + } + }, "GUIBuilder editor self-test").start()); + exercise.setRepeats(false); + exercise.start(); + } + + private static JTextPane findTextPane(java.awt.Container parent) { + for (java.awt.Component child : parent.getComponents()) { + if (child instanceof JTextPane) return (JTextPane) child; + if (child instanceof java.awt.Container) { + JTextPane found = findTextPane((java.awt.Container) child); + if (found != null) return found; + } + } + return null; + } + + private static void scheduleGuidedLayoutSelfTest() { + if (!Boolean.getBoolean("guibuilder.guidedSelfTest")) return; + Timer exercise = new Timer(1800, e -> new Thread(() -> { + try { + SwingUtilities.invokeAndWait(() -> { + frame.setAlwaysOnTop(true); + frame.toFront(); + frame.requestFocus(); + }); + final int[][] initial = new int[1][]; + final int[][] sameWidth = new int[1][]; + final int[] baselines = new int[2]; + Display.getInstance().callSerially(() -> { + initial[0] = CodenameOneGUIBuilder.activePreviewBoundsForTest("primary"); + sameWidth[0] = CodenameOneGUIBuilder.activePreviewBoundsForTest("secondary"); + baselines[0] = CodenameOneGUIBuilder.activePreviewBaselineForTest("baselineLabel"); + baselines[1] = CodenameOneGUIBuilder.activePreviewBaselineForTest("baselineAction"); + }); + waitFor(() -> initial[0] != null && sameWidth[0] != null && baselines[0] >= 0 && baselines[1] >= 0, 2500); + if (initial[0][2] != sameWidth[0][2]) throw new AssertionError("Initial same-width relationship is not rendered"); + if (Math.abs(baselines[0] - baselines[1]) > 1) throw new AssertionError("Baseline relationship differs by " + + Math.abs(baselines[0] - baselines[1]) + "px"); + + Robot robot = new Robot(); + robot.setAutoDelay(55); + java.awt.Container canvas = JavaSEPort.instance.getCanvas(); + java.awt.Point canvasPoint = canvas.getLocationOnScreen(); + double scale = JavaSEPort.getRetinaScale(); + int startX = canvasPoint.x + (int) Math.round((initial[0][0] + initial[0][2] / 2.0) / scale); + int startY = canvasPoint.y + (int) Math.round((initial[0][1] + initial[0][3] / 2.0) / scale); + System.out.println("GUIDED_SELF_TEST_COORDS frame=" + frame.getBounds() + " canvas=" + canvas.getBounds() + + " canvasScreen=" + canvasPoint + " scale=" + scale + " initial=" + + java.util.Arrays.toString(initial[0]) + " robotStart=" + startX + "," + startY); + drag(robot, startX, startY, startX + (int) Math.round(70 / scale), startY + (int) Math.round(60 / scale)); + Thread.sleep(650); + + final int[][] moved = new int[1][]; + long moveDeadline = System.currentTimeMillis() + 2500; + while (System.currentTimeMillis() < moveDeadline) { + moved[0] = activeBounds("primary"); + if (moved[0] != null && (moved[0][0] != initial[0][0] || moved[0][1] != initial[0][1])) break; + Thread.sleep(60); + } + if (moved[0] == null || moved[0][0] == initial[0][0] && moved[0][1] == initial[0][1]) { + throw new AssertionError("Physical body drag did not change bounds: initial=" + + java.util.Arrays.toString(initial[0]) + " last=" + java.util.Arrays.toString(moved[0]) + + " designer=" + activeDesignerState()); + } + if (Math.abs(moved[0][0] - initial[0][0]) < 30 || Math.abs(moved[0][1] - initial[0][1]) < 25) { + throw new AssertionError("Physical body drag did not commit near the guide: initial=" + + java.util.Arrays.toString(initial[0]) + " moved=" + java.util.Arrays.toString(moved[0])); + } + + int resizeX = canvasPoint.x + (int) Math.round((moved[0][0] + moved[0][2]) / scale); + int resizeY = canvasPoint.y + (int) Math.round((moved[0][1] + moved[0][3] / 2.0) / scale); + drag(robot, resizeX, resizeY, resizeX + (int) Math.round(100 / scale), resizeY); + Thread.sleep(650); + + final int[][] resized = new int[1][]; + final int[][] linked = new int[1][]; + final String[] policy = new String[1]; + long resizeDeadline = System.currentTimeMillis() + 2500; + while (System.currentTimeMillis() < resizeDeadline) { + resized[0] = activeBounds("primary"); + linked[0] = activeBounds("secondary"); + policy[0] = activeAttribute("primary", "guidedHorizontalSize"); + if (resized[0] != null && linked[0] != null && resized[0][2] > moved[0][2] + 50) break; + Thread.sleep(60); + } + if (resized[0] == null || resized[0][2] <= moved[0][2] + 50) { + throw new AssertionError("Physical edge resize did not grow the width: moved=" + + java.util.Arrays.toString(moved[0]) + " last=" + java.util.Arrays.toString(resized[0])); + } + if (Math.abs(resized[0][0] - moved[0][0]) > 2 || Math.abs(resized[0][1] - moved[0][1]) > 2) { + throw new AssertionError("Right-edge resize moved an untouched edge: moved=" + + java.util.Arrays.toString(moved[0]) + " resized=" + java.util.Arrays.toString(resized[0])); + } + if (!"fixed".equals(policy[0])) throw new AssertionError("Resize did not persist the fixed horizontal policy: " + policy[0]); + if (resized[0][2] != linked[0][2]) throw new AssertionError("Linked width did not update after physical resize: " + + resized[0][2] + " vs " + linked[0][2]); + System.out.println("GUIBUILDER_GUIDED_LAYOUT_SELF_TEST_PASS moved=" + + java.util.Arrays.toString(moved[0]) + " resized=" + java.util.Arrays.toString(resized[0])); + if (!"false".equals(System.getProperty("guibuilder.guidedSelfTest.exit"))) System.exit(0); + } catch (Throwable failure) { + failure.printStackTrace(); + System.err.println("GUIBUILDER_GUIDED_LAYOUT_SELF_TEST_FAIL"); + System.exit(3); + } + }, "GUIBuilder guided-layout self-test").start()); + exercise.setRepeats(false); + exercise.start(); + } + + private static void scheduleInteractionSelfTest() { + if (!Boolean.getBoolean("guibuilder.interactionSelfTest")) return; + Timer exercise = new Timer(1800, e -> new Thread(() -> { + try { + SwingUtilities.invokeAndWait(() -> { + frame.setAlwaysOnTop(true); + frame.toFront(); + frame.requestFocus(); + }); + int[] primary = activeBounds("primary"); + int[] secondary = activeBounds("secondary"); + if (primary == null || secondary == null) throw new AssertionError("Guided fixture components are missing"); + Robot robot = new Robot(); + robot.setAutoDelay(55); + clickCn1(robot, secondary[0] + secondary[2] / 2, secondary[1] + secondary[3] / 2); + waitFor(() -> "secondary".equals(CodenameOneGUIBuilder.activeSelectedNameForTest()), 2000); + clickCn1(robot, primary[0] + primary[2] / 2, primary[1] + primary[3] / 2); + waitFor(() -> "primary".equals(CodenameOneGUIBuilder.activeSelectedNameForTest()), 2000); + + int[] desktop = activeNamedUiBounds("Desktop — full canvas"); + if (desktop == null) throw new AssertionError("Desktop canvas-mode button is missing"); + System.out.println("INTERACTION_SELF_TEST_DESKTOP bounds=" + java.util.Arrays.toString(desktop) + + " designer=" + activeDesignerState() + " mode=" + CodenameOneGUIBuilder.activeCanvasModeForTest()); + clickCn1(robot, desktop[0] + desktop[2] / 2, desktop[1] + desktop[3] / 2); + try { + waitFor(() -> "desktop".equals(CodenameOneGUIBuilder.activeCanvasModeForTest()), 2500); + } catch (AssertionError timeout) { + throw new AssertionError("Desktop mode click failed: bounds=" + java.util.Arrays.toString(desktop) + + " designer=" + activeDesignerState() + " mode=" + + CodenameOneGUIBuilder.activeCanvasModeForTest(), timeout); + } + int[] landscape = activeNamedUiBounds("Phone landscape"); + if (landscape == null) throw new AssertionError("Phone landscape button is missing after refresh"); + clickCn1(robot, landscape[0] + landscape[2] / 2, landscape[1] + landscape[3] / 2); + waitFor(() -> "phoneLandscape".equals(CodenameOneGUIBuilder.activeCanvasModeForTest()), 2500); + System.out.println("GUIBUILDER_INTERACTION_SELF_TEST_PASS selection=primary mode=phoneLandscape"); + System.exit(0); + } catch (Throwable failure) { + failure.printStackTrace(); + System.err.println("GUIBUILDER_INTERACTION_SELF_TEST_FAIL"); + System.exit(4); + } + }, "GUIBuilder interaction self-test").start()); + exercise.setRepeats(false); + exercise.start(); + } + + private static int[] activeNamedUiBounds(String name) throws Exception { + final int[][] value = new int[1][]; + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + Display.getInstance().callSerially(() -> { + value[0] = CodenameOneGUIBuilder.activeNamedUiBoundsForTest(name); + latch.countDown(); + }); + if (!latch.await(1, java.util.concurrent.TimeUnit.SECONDS)) throw new AssertionError("EDT did not return UI bounds"); + return value[0]; + } + + private static void clickCn1(Robot robot, int cn1X, int cn1Y) throws Exception { + java.awt.Container canvas = JavaSEPort.instance.getCanvas(); + java.awt.Point canvasPoint = canvas.getLocationOnScreen(); + double scale = JavaSEPort.getRetinaScale(); + robot.mouseMove(canvasPoint.x + (int) Math.round(cn1X / scale), + canvasPoint.y + (int) Math.round(cn1Y / scale)); + robot.mousePress(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.delay(120); + robot.mouseRelease(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.waitForIdle(); + } + + private static void drag(Robot robot, int fromX, int fromY, int toX, int toY) { + robot.mouseMove(fromX, fromY); + robot.delay(180); + robot.mousePress(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.delay(220); + int steps = 12; + for (int i = 1; i <= steps; i++) { + robot.mouseMove(fromX + (toX - fromX) * i / steps, fromY + (toY - fromY) * i / steps); + robot.delay(35); + } + robot.delay(180); + robot.mouseRelease(java.awt.event.InputEvent.BUTTON1_DOWN_MASK); + robot.waitForIdle(); + } + + private static void waitFor(java.util.function.BooleanSupplier condition, long timeout) throws Exception { + long deadline = System.currentTimeMillis() + timeout; + while (!condition.getAsBoolean() && System.currentTimeMillis() < deadline) Thread.sleep(30); + if (!condition.getAsBoolean()) throw new AssertionError("Timed out waiting for Guided Layout UI state"); + } + + private static int[] activeBounds(String name) throws Exception { + final int[][] value = new int[1][]; + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + Display.getInstance().callSerially(() -> { + value[0] = CodenameOneGUIBuilder.activePreviewBoundsForTest(name); + latch.countDown(); + }); + if (!latch.await(1, java.util.concurrent.TimeUnit.SECONDS)) throw new AssertionError("EDT did not return preview bounds"); + return value[0]; + } + + private static String activeAttribute(String name, String attribute) throws Exception { + final String[] value = new String[1]; + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + Display.getInstance().callSerially(() -> { + value[0] = CodenameOneGUIBuilder.activeDocumentAttributeForTest(name, attribute); + latch.countDown(); + }); + if (!latch.await(1, java.util.concurrent.TimeUnit.SECONDS)) throw new AssertionError("EDT did not return document attribute"); + return value[0]; + } + + private static String activeDesignerState() throws Exception { + final String[] value = new String[1]; + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + Display.getInstance().callSerially(() -> { + value[0] = CodenameOneGUIBuilder.activeDesignerStateForTest(); + latch.countDown(); + }); + latch.await(1, java.util.concurrent.TimeUnit.SECONDS); + return value[0]; + } + + private static void installMenus() { + JMenuBar bar = new JMenuBar(); + JMenu file = new JMenu("File"); + file.add(item("Save Form", KeyEvent.VK_S, true, CodenameOneGUIBuilder::saveActiveDocument)); + file.add(item("Reload Project Forms", KeyEvent.VK_R, true, CodenameOneGUIBuilder::refreshActiveProject)); + file.addSeparator(); + file.add(item("Close GUI Builder", KeyEvent.VK_W, true, () -> Display.getInstance().exitApplication())); + + JMenu edit = new JMenu("Edit"); + edit.add(item("Undo", KeyEvent.VK_Z, true, CodenameOneGUIBuilderStub::undoFocusedEditorOrForm)); + JMenuItem redo = item("Redo", 0, false, CodenameOneGUIBuilderStub::redoFocusedEditorOrForm); + redo.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Z, + menuShortcutMask() | KeyEvent.SHIFT_DOWN_MASK)); + edit.add(redo); + edit.addSeparator(); + edit.add(item("Cut", KeyEvent.VK_X, true, () -> editFocusedTextOrForm("cut"))); + edit.add(item("Copy", KeyEvent.VK_C, true, () -> editFocusedTextOrForm("copy"))); + edit.add(item("Paste", KeyEvent.VK_V, true, () -> editFocusedTextOrForm("paste"))); + edit.addSeparator(); + // Bare Backspace, so it competes with typing: guard it, or deleting a character in the code + // editor deletes the selected component from the design instead. + edit.add(item("Delete Component", KeyEvent.VK_BACK_SPACE, false, + () -> { if (!isCodeEditorFocused()) CodenameOneGUIBuilder.deleteActiveSelection(); })); + + JMenu view = new JMenu("View"); + JCheckBoxMenuItem dark = new JCheckBoxMenuItem("Dark Mode", CodenameOneGUIBuilder.isActiveDarkMode()); + dark.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_D, menuShortcutMask())); + dark.addActionListener(e -> CodenameOneGUIBuilder.toggleActiveDarkMode()); + view.add(dark); + view.addSeparator(); + view.add(item("Refresh Canvas", KeyEvent.VK_0, true, CodenameOneGUIBuilder::refreshActiveProject)); + + JMenu forms = new JMenu("Forms"); + String[] names = CodenameOneGUIBuilder.activeFormNames(); + for (int i = 0; i < names.length; i++) { + final int formIndex = i; + String name = names[i]; + String simple = name.substring(name.lastIndexOf('.') + 1); + JMenuItem form = item(simple, i < 9 ? KeyEvent.VK_1 + i : 0, i < 9, + () -> CodenameOneGUIBuilder.openActiveForm(formIndex)); + form.setToolTipText(name); + forms.add(form); + } + + JMenu code = new JMenu("Editors"); + code.add(item("Edit Companion Java Source", KeyEvent.VK_J, true, CodenameOneGUIBuilder::openActiveSource)); + code.add(item("Edit Binding Model", KeyEvent.VK_K, true, CodenameOneGUIBuilder::openActiveModel)); + code.add(item("Edit theme.css with Live Preview", KeyEvent.VK_T, true, CodenameOneGUIBuilder::openActiveCss)); + bar.add(file); + bar.add(edit); + bar.add(view); + bar.add(forms); + bar.add(code); + frame.setJMenuBar(bar); + installAboutHandler(frame); + } + + /** + * The menu shortcut modifier. {@code getMenuShortcutKeyMaskEx} arrived in Java 10 and this + * editor is a Java 8 artifact, so the older accessor is used; it reports the legacy mask + * constants, which {@code KeyStroke} and {@code InputEvent} still understand. + */ + @SuppressWarnings("deprecation") + private static int menuShortcutMask() { + // Converted to the extended constants the call sites use. Java 8 only offers the legacy + // accessor, and mixing a legacy mask into an accelerator built from *_DOWN_MASK values + // yields a modifier combination that never matches. + int legacy = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask(); + if ((legacy & java.awt.event.InputEvent.META_MASK) != 0) return java.awt.event.InputEvent.META_DOWN_MASK; + if ((legacy & java.awt.event.InputEvent.ALT_MASK) != 0) return java.awt.event.InputEvent.ALT_DOWN_MASK; + if ((legacy & java.awt.event.InputEvent.SHIFT_MASK) != 0) return java.awt.event.InputEvent.SHIFT_DOWN_MASK; + return java.awt.event.InputEvent.CTRL_DOWN_MASK; + } + + /** + * Installs the About entry in the application menu. {@code Desktop.Action.APP_ABOUT} and + * {@code setAboutHandler} are Java 9 additions, so they are invoked reflectively: on 8 the menu + * simply has no About entry rather than the whole editor failing to build. + * + * @param frame the window the dialog is shown over + */ + private static void installAboutHandler(final JFrame frame) { + try { + Class actionClass = Class.forName("java.awt.Desktop$Action"); + Object appAbout = actionClass.getField("APP_ABOUT").get(null); + if (!Desktop.isDesktopSupported()) return; + Desktop desktop = Desktop.getDesktop(); + java.lang.reflect.Method supported = Desktop.class.getMethod("isSupported", actionClass); + if (!Boolean.TRUE.equals(supported.invoke(desktop, appAbout))) return; + Class handlerClass = Class.forName("java.awt.desktop.AboutHandler"); + Object handler = java.lang.reflect.Proxy.newProxyInstance( + CodenameOneGUIBuilderStub.class.getClassLoader(), new Class[]{handlerClass}, + (proxy, method, args) -> { + if ("handleAbout".equals(method.getName())) { + javax.swing.JOptionPane.showMessageDialog(frame, + "Modern Maven-first visual editor for Codename One GUI forms.", + APP_DISPLAY_NAME, javax.swing.JOptionPane.INFORMATION_MESSAGE); + } + return null; + }); + Desktop.class.getMethod("setAboutHandler", handlerClass).invoke(desktop, handler); + } catch (Throwable olderJdkOrHeadless) { + // Java 8, or a desktop that does not offer an application menu. + } + } + + private static UndoManager focusedEditorUndoManager() { + java.awt.Component focus = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + if (!(focus instanceof JComponent)) return null; + Object value = ((JComponent) focus).getClientProperty("cn1.codeEditorUndoManager"); + return value instanceof UndoManager ? (UndoManager) value : null; + } + + private static void undoFocusedEditorOrForm() { + UndoManager manager = focusedEditorUndoManager(); + if (manager != null) { + if (manager.canUndo()) manager.undo(); + } else CodenameOneGUIBuilder.undoActiveEdit(); + } + + private static void redoFocusedEditorOrForm() { + UndoManager manager = focusedEditorUndoManager(); + if (manager != null) { + if (manager.canRedo()) manager.redo(); + } else CodenameOneGUIBuilder.redoActiveEdit(); + } + + private static void editFocusedTextOrForm(String operation) { + // A menu accelerator consumes the keystroke before Codename One ever sees it, and the AWT + // focus owner is always the canvas rather than a Swing text component, so without this the + // Edit menu treated Cmd+V inside the code editor as "paste a component into the design". + // That rebuilt the canvas and tore down the split pane the editor was living in. + if (editFocusedCodeEditor(operation)) return; + java.awt.Component focus = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + if (focus instanceof JTextComponent) { + JTextComponent text = (JTextComponent) focus; + if ("cut".equals(operation)) text.cut(); + else if ("copy".equals(operation)) text.copy(); + else text.paste(); + return; + } + if ("cut".equals(operation)) CodenameOneGUIBuilder.cutActiveSelection(); + else if ("copy".equals(operation)) CodenameOneGUIBuilder.copyActiveSelection(); + else CodenameOneGUIBuilder.pasteActiveSelection(); + } + + /** True while a Codename One code editor holds the focus and should own the keyboard. */ + private static boolean isCodeEditorFocused() { + com.codename1.ui.Form form = com.codename1.ui.CN.getCurrentForm(); + return form != null && form.getFocused() instanceof com.codename1.ui.editor.EditorView; + } + + /** Routes a clipboard operation to the Codename One code editor when it holds the focus. */ + private static boolean editFocusedCodeEditor(String operation) { + com.codename1.ui.Form form = com.codename1.ui.CN.getCurrentForm(); + com.codename1.ui.Component focused = form == null ? null : form.getFocused(); + if (!(focused instanceof com.codename1.ui.editor.EditorView)) return false; + final com.codename1.ui.editor.EditorView view = (com.codename1.ui.editor.EditorView) focused; + if (view.getComponentForm() == null) return false; + com.codename1.ui.CN.callSerially(new Runnable() { + @Override public void run() { + if ("cut".equals(operation)) view.cutSelection(); + else if ("copy".equals(operation)) view.copySelection(); + else view.pasteClipboard(); + } + }); + return true; + } + + private static JMenuItem item(String label, int key, boolean menuShortcut, Runnable action) { + JMenuItem item = new JMenuItem(label); + if (key > 0) { + int mask = menuShortcut ? menuShortcutMask() : 0; + item.setAccelerator(KeyStroke.getKeyStroke(key, mask)); + } + item.addActionListener(e -> action.run()); + return item; + } + + private static void scheduleScreenshot() { + String output = System.getProperty("guibuilder.screenshot"); + if (output == null || output.length() == 0) return; + Timer timer = new Timer(1600, e -> { + try { + frame.setAlwaysOnTop(true); + frame.toFront(); + BufferedImage image = new Robot().createScreenCapture(frame.getBounds()); + ImageIO.write(image, "png", new File(output)); + } catch (Exception ex) { + ex.printStackTrace(); + } finally { + if (!"false".equals(System.getProperty("guibuilder.screenshot.exit"))) Display.getInstance().exitApplication(); + } + }); + timer.setRepeats(false); + timer.start(); + } + + @Override public void windowClosing(WindowEvent e) { Display.getInstance().exitApplication(); } + @Override public void windowOpened(WindowEvent e) { } + @Override public void windowClosed(WindowEvent e) { } + @Override public void windowIconified(WindowEvent e) { } + @Override public void windowDeiconified(WindowEvent e) { } + @Override public void windowActivated(WindowEvent e) { } + @Override public void windowDeactivated(WindowEvent e) { } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/CodeEditorInteractionTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/CodeEditorInteractionTest.java new file mode 100644 index 00000000000..e05b9c956e6 --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/CodeEditorInteractionTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ui.TextInputClient; +import com.codename1.ui.TextInputConfig; +import com.codename1.ui.TextInputState; +import com.codename1.ui.editor.CodePureEditor; +import com.codename1.ui.editor.CodeView; +import com.codename1.ui.editor.EditorHost; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CodeEditorInteractionTest { + @Test + void pureEditorAcceptsUserEditsUndoRedoAndProtectsOnlyGeneratedBlocks() { + CodePureEditor editor = new CodePureEditor(new TestEditorHost(), "code"); + CodeView view = (CodeView) editor.getView(); + String source = "// \nfinal int locked = 1;\n// \n" + + "// \nvoid handler() { }\n// \n"; + editor.cmd("setText", source); + editor.cmd("setProtectedMarkers", "// \n// "); + + int userOffset = source.indexOf("void handler"); + view.replaceRange(userOffset, userOffset, "public "); + assertTrue(editor.query("getText", null).contains("public void handler"), + "typing in the user region must mutate the document"); + + editor.cmd("undo", null); + assertFalse(editor.query("getText", null).contains("public void handler"), + "source and CSS edits must enter the editor-local undo buffer"); + editor.cmd("redo", null); + assertTrue(editor.query("getText", null).contains("public void handler")); + + String beforeProtectedAttempt = editor.query("getText", null); + int generatedOffset = beforeProtectedAttempt.indexOf("final int"); + view.replaceRange(generatedOffset, generatedOffset, "BROKEN "); + assertEquals(beforeProtectedAttempt, editor.query("getText", null), + "generated regions alone must reject edits"); + } + + @Test + void guiBuilderCanMoveThePureEditorCaretToAnExactSourceOffset() { + CodePureEditor editor = new CodePureEditor(new TestEditorHost(), "code"); + editor.cmd("setText", "alpha beta gamma"); + editor.cmd("setCursor", "11"); + assertEquals("11", editor.query("getCursor", null)); + + editor.cmd("setCursor", "999"); + assertEquals("16", editor.query("getCursor", null), "caret must clamp to the document"); + } + + private static final class TestEditorHost implements EditorHost { + @Override + public boolean isTextInputSupported() { + return false; + } + + @Override + public Object startTextInput(TextInputClient client, TextInputConfig config) { + return null; + } + + @Override + public void updateTextInputState(Object handle, TextInputState state) { + } + + @Override + public void stopTextInput(Object handle) { + } + + @Override + public void editorChanged() { + } + + @Override + public void fireEditorEvent(String type, String value) { + } + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/DemoFormsTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/DemoFormsTest.java new file mode 100644 index 00000000000..70c16a37a3f --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/DemoFormsTest.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.guibuilder.model.GuiDocument; +import com.codename1.guibuilder.ui.ComponentPreviewFactory; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.xml.Element; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The demo project is the surface anyone evaluating the editor actually opens, so a form that + * parses but renders nothing is a broken first impression. Every form is checked for the same + * things the designer relies on: unique names, a coherent tree, and every component visible. + */ +class DemoFormsTest { + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!com.codename1.ui.Display.isInitialized()) com.codename1.ui.Display.init(new JPanel()); + } + + @Test + void everyDemoFormLoadsRendersAndKeepsEveryComponentVisible() throws Exception { + List forms = demoForms(); + assertTrue(forms.size() >= 12, "expected the demo project to cover more cases: " + forms); + for (File file : forms) { + GuiDocument document = GuiDocument.parse(file.getPath(), + new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + + Set names = new LinkedHashSet<>(); + for (Element element : document.components()) { + String name = element.getAttribute("name"); + assertNotNull(name, file.getName() + " has a component with no name"); + assertTrue(names.add(name), file.getName() + " reuses the name " + name + + ", which collides in the generated source"); + if (element == document.root()) continue; + assertNotNull(document.parentOf(element), + file.getName() + ": " + name + " is detached from the tree"); + } + + Container rendered = (Container) render(document); + for (Element element : document.components()) { + if (element == document.root()) continue; + Component preview = findPreview(rendered, element); + assertNotNull(preview, file.getName() + ": " + element.getAttribute("name") + + " does not render at all"); + assertTrue(preview.getWidth() > 0 && preview.getHeight() > 0, + file.getName() + ": " + element.getAttribute("name") + " renders at " + + preview.getWidth() + "x" + preview.getHeight() + "; it is invisible"); + } + } + } + + @Test + void everyDemoFormSurvivesASaveAndReload() throws Exception { + for (File file : demoForms()) { + GuiDocument document = GuiDocument.parse(file.getPath(), + new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + GuiDocument reloaded = GuiDocument.parse(file.getPath(), document.toXml()); + assertEquals(structure(document), structure(reloaded), + file.getName() + " does not survive a save/load round trip"); + assertEquals(reloaded.toXml(), GuiDocument.parse(file.getPath(), reloaded.toXml()).toXml(), + file.getName() + " keeps changing every time it is written"); + } + } + + private static List demoForms() { + File directory = new File("../demo-project/src/main/guibuilder/com/example"); + assertTrue(directory.isDirectory(), "demo forms are missing at " + directory.getAbsolutePath()); + List forms = new ArrayList<>(); + for (File file : directory.listFiles()) { + if (file.getName().endsWith(".gui")) forms.add(file); + } + java.util.Collections.sort(forms); + return forms; + } + + private static List structure(GuiDocument document) { + List rows = new ArrayList<>(); + for (Element element : document.components()) { + Element parent = document.parentOf(element); + rows.add((parent == null ? "-" : parent.getAttribute("name")) + "/" + element.getAttribute("name")); + } + return rows; + } + + private static Component render(GuiDocument document) { + Component rendered = ComponentPreviewFactory.create(document.root(), null, + new ComponentPreviewFactory.SelectionHandler() { + public void selected(Element element) { } + public void dragPressed(Element element, Component source, int x, int y) { } + public boolean isDragActive() { return false; } + public void editContent(Element element) { } + }); + rendered.setWidth(720); + rendered.setHeight(1200); + layoutNested((Container) rendered); + return rendered; + } + + private static void layoutNested(Container container) { + container.layoutContainer(); + for (int i = 0; i < container.getComponentCount(); i++) { + Component child = container.getComponentAt(i); + if (child instanceof Container) layoutNested((Container) child); + } + } + + private static Component findPreview(Container root, Element element) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component.getClientProperty("gui.element") == element) return component; + if (component instanceof Container) { + Component nested = findPreview(((Container) component), element); + if (nested != null) return nested; + } + } + return null; + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/DesignerInteractionTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/DesignerInteractionTest.java new file mode 100644 index 00000000000..6f957c9a72c --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/DesignerInteractionTest.java @@ -0,0 +1,1583 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.guibuilder.model.GuiDocument; +import com.codename1.guibuilder.project.ProjectBinding; +import com.codename1.guibuilder.ui.ComponentPreviewFactory; +import com.codename1.mcp.MCP; +import com.codename1.ui.Component; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.xml.Element; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DesignerInteractionTest { + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!com.codename1.ui.Display.isInitialized()) com.codename1.ui.Display.init(new JPanel()); + } + + @Test + void aFixedGuidedWidthNeverFreezesTheThemeDerivedHeight() { + GuiDocument document = document("" + + "" + + "" + + ""); + Container rendered = (Container) ComponentPreviewFactory.create(document.root(), null, handler()); + Component fixed = rendered.getComponentAt(0); + Component natural = rendered.getComponentAt(1); + + fixed.getAllStyles().setPadding(24, 24, 10, 10); + natural.getAllStyles().setPadding(24, 24, 10, 10); + + assertEquals(300, fixed.getPreferredW()); + assertEquals(natural.getPreferredH(), fixed.getPreferredH(), + "fixing one axis must leave the other axis responsive to UIID/CSS metrics"); + assertFalse(fixed.hasFixedPreferredSize(), + "the designer must not use deprecated setPreferredW/H or preferredSizeStr state"); + } + + @Test + void boxSpacerKeepsTheExactSiblingAsItsHitTarget() throws Exception { + GuiDocument document = document(""); + Element b = document.components().get(2); + CodenameOneGUIBuilder builder = builder(document); + Container host = new Container(new LayeredLayout()); + host.setWidth(400); + host.setHeight(400); + Label spacer = new Label(); + spacer.putClientProperty("gui.dropTargetElement", b); + spacer.setX(20); + spacer.setY(40); + spacer.setWidth(200); + spacer.setHeight(24); + host.add(spacer); + + assertSame(b, builder.elementAt(host, 30, 45), + "the animated gap must not turn a precise insertion into a parent/end drop"); + } + + @Test + void aNewPressSelectsAnotherComponentWithoutRequiringAnyDrag() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Container preview = (Container) render(document, 700, 400); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(700); canvas.setHeight(400); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component firstPreview = componentForElement(preview, first); + Component secondPreview = componentForElement(preview, second); + document.select(first); + + builder.handleDesignerPointerPressed(secondPreview.getAbsoluteX() + secondPreview.getWidth() / 2, + secondPreview.getAbsoluteY() + secondPreview.getHeight() / 2); + assertSame(second, document.selected(), "selection must change on press, before drag threshold"); + + // A missed release must not lock the designer onto the old component. The next press is a + // new gesture and immediately selects its own hit target. + builder.handleDesignerPointerPressed(firstPreview.getAbsoluteX() + firstPreview.getWidth() / 2, + firstPreview.getAbsoluteY() + firstPreview.getHeight() / 2); + assertSame(first, document.selected()); + } + + @Test + void modifierPressBuildsAMultiSelectionWithoutMovingAnything() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Container preview = (Container) render(document, 700, 400); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(700); canvas.setHeight(400); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component firstPreview = componentForElement(preview, first); + Component secondPreview = componentForElement(preview, second); + String before = document.toXml(); + + builder.handleDesignerPointerPressed(firstPreview.getAbsoluteX() + 10, firstPreview.getAbsoluteY() + 10, false); + builder.handleDesignerPointerPressed(secondPreview.getAbsoluteX() + 10, secondPreview.getAbsoluteY() + 10, true); + + Map state = builder.mcpState(0); + assertEquals(java.util.Arrays.asList("first", "second"), state.get("selectedComponents")); + assertSame(first, document.selected(), "modifier-click must preserve the original sizing reference"); + builder.handleDesignerPointerPressed(firstPreview.getAbsoluteX() + 10, firstPreview.getAbsoluteY() + 10, false); + assertEquals(java.util.Arrays.asList("first", "second"), builder.mcpState(0).get("selectedComponents"), + "pressing a selected member must preserve the group so it can become the drag handle"); + assertSame(first, document.selected()); + assertEquals(before, document.toXml(), "selection must never mutate layout data"); + } + + @Test + void sameWidthStretchesEverySelectionMemberToTheStableReferenceWidth() throws Exception { + GuiDocument document = document("" + + "" + + "" + + "" + + ""); + Element reference = document.components().get(1); + Element small = document.components().get(2); + Element medium = document.components().get(3); + Container preview = (Container) render(document, 760, 520); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(760); canvas.setHeight(520); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component referencePreview = componentForElement(preview, reference); + Component smallPreview = componentForElement(preview, small); + Component mediumPreview = componentForElement(preview, medium); + String originalXml = document.toXml(); + + builder.handleDesignerPointerPressed(referencePreview.getAbsoluteX() + referencePreview.getWidth() / 2, + referencePreview.getAbsoluteY() + referencePreview.getHeight() / 2, false); + builder.handleDesignerPointerPressed(smallPreview.getAbsoluteX() + smallPreview.getWidth() / 2, + smallPreview.getAbsoluteY() + smallPreview.getHeight() / 2, true); + builder.handleDesignerPointerPressed(mediumPreview.getAbsoluteX() + mediumPreview.getWidth() / 2, + mediumPreview.getAbsoluteY() + mediumPreview.getHeight() / 2, true); + assertSame(reference, document.selected()); + + builder.applySelectionAction("matchWidth"); + + Container matched = (Container) render(document, 760, 520); + assertEquals(320, componentForElement(matched, reference).getWidth(), 1); + assertEquals(320, componentForElement(matched, small).getWidth(), 1); + assertEquals(320, componentForElement(matched, medium).getWidth(), 1); + assertEquals("reference", small.getAttribute("guidedMatchWidth")); + assertEquals("reference", medium.getAttribute("guidedMatchWidth")); + assertTrue(document.undo()); + assertEquals(originalXml, document.toXml(), "same-size must be one atomic undo step"); + assertFalse(document.canUndo()); + } + + @Test + void groupedGuidedDropTranslatesEverySelectedRectangleAndIsOneUndoStep() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Container preview = (Container) render(document, 800, 500); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(800); canvas.setHeight(500); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component firstPreview = componentForElement(preview, first); + Component secondPreview = componentForElement(preview, second); + int[] firstBefore = bounds(firstPreview); + int[] secondBefore = bounds(secondPreview); + String xmlBefore = document.toXml(); + builder.handleDesignerPointerPressed(firstPreview.getAbsoluteX() + 10, firstPreview.getAbsoluteY() + 10, false); + builder.handleDesignerPointerPressed(secondPreview.getAbsoluteX() + 10, secondPreview.getAbsoluteY() + 10, true); + + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = firstPreview.getAbsoluteX() + 80; + plan.snapY = firstPreview.getAbsoluteY() + 55; + plan.snapW = firstPreview.getWidth(); plan.snapH = firstPreview.getHeight(); + assertTrue(builder.applyGroupedGuidedDrop(first, plan)); + + Container moved = (Container) render(document, 800, 500); + int[] firstAfter = bounds(componentForElement(moved, first)); + int[] secondAfter = bounds(componentForElement(moved, second)); + assertEquals(firstAfter[0] - firstBefore[0], secondAfter[0] - secondBefore[0]); + assertEquals(firstAfter[1] - firstBefore[1], secondAfter[1] - secondBefore[1]); + assertEquals(firstBefore[2], firstAfter[2]); + assertEquals(firstBefore[3], firstAfter[3]); + assertEquals(secondBefore[2], secondAfter[2]); + assertEquals(secondBefore[3], secondAfter[3]); + assertTrue(document.canUndo()); + assertTrue(document.undo()); + assertEquals(xmlBefore, document.toXml(), "the whole group move must undo atomically"); + assertFalse(document.canUndo(), "group movement must contribute exactly one undo entry"); + } + + @Test + void groupedDragPreviewShowsEveryMemberAndMatchesTheAtomicCommit() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Container preview = (Container) render(document, 800, 500); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(800); canvas.setHeight(500); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component firstPreview = componentForElement(preview, first); + Component secondPreview = componentForElement(preview, second); + builder.handleDesignerPointerPressed(firstPreview.getAbsoluteX() + 10, firstPreview.getAbsoluteY() + 10, false); + builder.handleDesignerPointerPressed(secondPreview.getAbsoluteX() + 10, secondPreview.getAbsoluteY() + 10, true); + + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = firstPreview.getAbsoluteX() + 95; + plan.snapY = firstPreview.getAbsoluteY() + 60; + plan.snapW = firstPreview.getWidth(); plan.snapH = firstPreview.getHeight(); + String originalXml = document.toXml(); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGroupedGuidedDrop(first, plan); + + assertNotNull(simulation); + assertEquals(originalXml, document.toXml(), "a group glass preview must not mutate the document"); + assertEquals(2, simulation.items.size(), "every selected component needs a moving glass rectangle"); + com.codename1.guibuilder.ui.DragGuideOverlay.GlassItem predictedFirst = simulation.items.stream() + .filter(item -> "first".equals(item.name)).findFirst().get(); + com.codename1.guibuilder.ui.DragGuideOverlay.GlassItem predictedSecond = simulation.items.stream() + .filter(item -> "second".equals(item.name)).findFirst().get(); + assertEquals(predictedFirst.newX - predictedFirst.oldX, predictedSecond.newX - predictedSecond.oldX); + assertEquals(predictedFirst.newY - predictedFirst.oldY, predictedSecond.newY - predictedSecond.oldY); + assertTrue(predictedFirst.active); + assertFalse(predictedSecond.active); + + assertTrue(builder.applyGroupedGuidedDrop(first, plan)); + Container committed = (Container) render(document, 800, 500); + Component committedFirst = componentForElement(committed, first); + Component committedSecond = componentForElement(committed, second); + assertEquals(predictedFirst.newX, committedFirst.getAbsoluteX(), 2); + assertEquals(predictedFirst.newY, committedFirst.getAbsoluteY(), 2); + assertEquals(predictedSecond.newX, committedSecond.getAbsoluteX(), 2); + assertEquals(predictedSecond.newY, committedSecond.getAbsoluteY(), 2); + } + + @Test + void groupedGuidedDropPreservesInternalBindingsAndLeavesOutsideDependentsInPlace() throws Exception { + GuiDocument document = document("" + + "" + + "" + + "" + + "" + + ""); + Element primary = document.components().get(2); + Element secondary = document.components().get(3); + Element outsideDependent = document.components().get(4); + Container preview = (Container) render(document, 900, 650); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(900); canvas.setHeight(650); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component primaryPreview = componentForElement(preview, primary); + Component secondaryPreview = componentForElement(preview, secondary); + int[] dependentBefore = bounds(componentForElement(preview, outsideDependent)); + builder.handleDesignerPointerPressed(primaryPreview.getAbsoluteX() + 10, primaryPreview.getAbsoluteY() + 10, false); + builder.handleDesignerPointerPressed(secondaryPreview.getAbsoluteX() + 10, secondaryPreview.getAbsoluteY() + 10, true); + + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = primaryPreview.getAbsoluteX() + 120; + plan.snapY = primaryPreview.getAbsoluteY() + 80; + plan.snapW = primaryPreview.getWidth(); plan.snapH = primaryPreview.getHeight(); + assertTrue(builder.applyGroupedGuidedDrop(primary, plan)); + + assertEquals("primary", secondary.getAttribute("guidedMatchWidth")); + assertTrue(secondary.getAttribute("guidedReferences").contains("primary"), + "relationships entirely inside the selection must survive a group move"); + assertFalse(primary.getAttribute("guidedReferences").contains("outsideAnchor"), + "the group root must detach from positioning references outside the selection"); + Container moved = (Container) render(document, 900, 650); + assertArrayEquals(dependentBefore, bounds(componentForElement(moved, outsideDependent)), + "an unselected dependent must be rebased rather than pulled along with the group"); + assertEquals(componentForElement(moved, primary).getWidth(), componentForElement(moved, secondary).getWidth(), 1); + } + + @Test + void layeredDropPersistsTheGuidedRectangleAndRerendersThere() throws Exception { + GuiDocument document = document(""); + Element card = document.components().get(1); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = new Container(new LayeredLayout()); + Container parentPreview = new Container(new LayeredLayout()); + parentPreview.putClientProperty("gui.element", document.root()); + parentPreview.setX(50); + parentPreview.setY(70); + parentPreview.setWidth(500); + parentPreview.setHeight(400); + Label source = new Label("Card"); + source.putClientProperty("gui.element", card); + source.setWidth(100); + source.setHeight(80); + parentPreview.add(source); + canvas.add(parentPreview); + set(builder, "canvasHost", canvas); + + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; + plan.target = document.root(); + plan.parent = document.root(); + plan.layout = "LayeredLayout"; + plan.valid = true; + plan.snapX = 150; + plan.snapY = 150; + assertTrue(builder.applyDropPlan(card, plan, plan.snapX, plan.snapY)); + // The exact numbers include the component's own margins, which the theme states in + // millimetres and therefore differ with the display density. What has to hold everywhere is + // that the drop was recorded as a fixed rectangle; where that rectangle lands is asserted + // below, on the rendered result. + String insets = card.getAttribute("layeredInsets"); + assertNotNull(insets, document.toXml()); + for (String side : insets.split(" ")) { + assertTrue(side.endsWith("px"), "a dropped rectangle must be pinned in pixels, was " + insets); + } + + Component rendered = ComponentPreviewFactory.create(document.root(), card, handler()); + rendered.setWidth(500); + rendered.setHeight(400); + ((Container) rendered).layoutContainer(); + Component renderedCard = ((Container) rendered).getComponentAt(0); + assertEquals(100, renderedCard.getX(), 2, "rerendered left edge must match the guide"); + assertEquals(80, renderedCard.getY(), 2, "rerendered top edge must match the guide"); + assertEquals(100, renderedCard.getWidth(), 5, "drop must preserve the guided width within percentage rounding"); + assertEquals(80, renderedCard.getHeight(), 5, "drop must preserve the guided height within percentage rounding"); + } + + @Test + void layeredGuideUsesPointerGrabOffsetAndCommittedBoundsMatchIt() throws Exception { + GuiDocument document = document(""); + Element card = document.components().get(1); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = new Container(new LayeredLayout()); + Container parentPreview = new Container(new LayeredLayout()); + parentPreview.putClientProperty("gui.element", document.root()); + parentPreview.setX(50); + parentPreview.setY(70); + parentPreview.setWidth(500); + parentPreview.setHeight(400); + Button source = new Button("Card"); + source.putClientProperty("gui.element", card); + source.setWidth(100); + source.setHeight(80); + parentPreview.add(source); + canvas.add(parentPreview); + set(builder, "canvasHost", canvas); + set(builder, "designerGrabOffsetX", 30); + set(builder, "designerGrabOffsetY", 20); + + CodenameOneGUIBuilder.DropPlan guide = builder.planDrop(card, document.root(), source, 293, 267); + assertNotNull(guide); + assertTrue(builder.applyDropPlan(card, guide, 293, 267)); + Component rendered = render(document, 500, 400); + Component renderedCard = componentForElement((Container) rendered, card); + + assertEquals(guide.snapX - parentPreview.getAbsoluteX(), renderedCard.getX(), 2, + "the committed left edge must be the guide left edge, not the raw pointer"); + assertEquals(guide.snapY - parentPreview.getAbsoluteY(), renderedCard.getY(), 2); + assertEquals(source.getWidth(), renderedCard.getWidth(), 2); + assertEquals(source.getHeight(), renderedCard.getHeight(), 2); + } + + @Test + void dockingToTheSurfaceEdgeReflowsWhenTheSurfaceWidthChanges() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Element card = document.components().get(1); + Container preview = (Container) render(document, 600, 320); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(600); canvas.setHeight(320); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component source = componentForElement(preview, card); + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = 600 - source.getWidth(); plan.snapY = source.getAbsoluteY(); + plan.snapW = source.getWidth(); plan.snapH = source.getHeight(); + plan.horizontalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapX, "dock right", null, "parentEnd"); + plan.verticalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapY, null, null, null); + assertTrue(builder.applyDropPlan(card, plan, plan.snapX, plan.snapY)); + + Container narrow = (Container) render(document, 600, 320); + Container wide = (Container) render(document, 900, 320); + Component narrowCard = componentForElement(narrow, card); + Component wideCard = componentForElement(wide, card); + assertEquals(narrowCard.getWidth(), wideCard.getWidth()); + assertEquals(300, wideCard.getX() - narrowCard.getX(), 1, + "right docking must reflow by the surface width delta, not preserve an absolute x"); + // The distance kept from the docked edge comes from the display density, so it is compared + // between the two surfaces rather than against a pixel count measured on one machine. + int narrowMargin = 600 - (narrowCard.getX() + narrowCard.getWidth()); + int wideMargin = 900 - (wideCard.getX() + wideCard.getWidth()); + assertEquals(narrowMargin, wideMargin, "docking must keep the same distance from either edge"); + assertTrue(narrowMargin >= 0 && narrowMargin <= 12, + "a component docked right must sit at the edge, but it was " + narrowMargin + "px away"); + } + + @Test + void shortGuidedDragOverItsOwnPixelsTargetsTheParentInsteadOfCancelling() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Element card = document.components().get(1); + CodenameOneGUIBuilder builder = builder(document); + + assertSame(document.root(), builder.normalizeDesignerDropTarget(card, card)); + } + + @Test + void guidedReferencesRemainResponsiveAndMatchWidthAcrossCanvasSizes() { + GuiDocument document = document("" + + "" + + "" + + ""); + Element anchor = document.components().get(1); + Element linked = document.components().get(2); + + Container narrow = (Container) render(document, 420, 260); + Component narrowAnchor = componentForElement(narrow, anchor); + Component narrowLinked = componentForElement(narrow, linked); + assertEquals(narrowAnchor.getWidth(), narrowLinked.getWidth(), 1); + assertEquals(40, narrowLinked.getX() - narrowAnchor.getX(), 1, + "same-size links may retain an independent position offset"); + + Container wide = (Container) render(document, 760, 260); + Component wideAnchor = componentForElement(wide, anchor); + Component wideLinked = componentForElement(wide, linked); + assertEquals(wideAnchor.getWidth(), wideLinked.getWidth(), 1, + "same width must be a relationship, not a one-time pixel copy"); + assertEquals(40, wideLinked.getX() - wideAnchor.getX(), 1); + } + + @Test + void baselineConstraintAlignsDifferentTextComponentsExactly() { + GuiDocument document = document("" + + "" + + "" + + ""); + Element caption = document.components().get(1); + Element action = document.components().get(2); + Container rendered = (Container) render(document, 500, 220); + Component renderedCaption = componentForElement(rendered, caption); + Component renderedAction = componentForElement(rendered, action); + + int captionBaseline = renderedCaption.getY() + renderedCaption.getBaseline(renderedCaption.getWidth(), renderedCaption.getHeight()); + int actionBaseline = renderedAction.getY() + renderedAction.getBaseline(renderedAction.getWidth(), renderedAction.getHeight()); + assertEquals(captionBaseline, actionBaseline, 1); + } + + @Test + void centerAnchorsCenterComponentsRatherThanTheirLeftEdges() { + GuiDocument document = document("" + + "" + + ""); + Element centered = document.components().get(1); + Container rendered = (Container) render(document, 600, 400); + Component component = componentForElement(rendered, centered); + assertEquals(300, component.getX() + component.getWidth() / 2, 2); + assertEquals(200, component.getY() + component.getHeight() / 2, 2); + } + + @Test + void selectingAComponentNeverChangesLayoutMetrics() { + GuiDocument document = document("" + + "" + + "" + + ""); + Element first = document.components().get(1); + Element second = document.components().get(2); + Container unselected = (Container) render(document, 600, 300); + Component selectedRender = ComponentPreviewFactory.create(document.root(), first, handler()); + selectedRender.setWidth(600); selectedRender.setHeight(300); + layoutNested((Container) selectedRender); + + Component beforeFirst = componentForElement(unselected, first); + Component afterFirst = componentForElement((Container) selectedRender, first); + Component beforeSecond = componentForElement(unselected, second); + Component afterSecond = componentForElement((Container) selectedRender, second); + assertArrayEquals(new int[]{beforeFirst.getX(), beforeFirst.getY(), beforeFirst.getWidth(), beforeFirst.getHeight()}, + new int[]{afterFirst.getX(), afterFirst.getY(), afterFirst.getWidth(), afterFirst.getHeight()}); + assertArrayEquals(new int[]{beforeSecond.getX(), beforeSecond.getY(), beforeSecond.getWidth(), beforeSecond.getHeight()}, + new int[]{afterSecond.getX(), afterSecond.getY(), afterSecond.getWidth(), afterSecond.getHeight()}); + } + + @Test + void resizeSnapCopiesTheSizeWithoutInventingADurableBinding() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element anchor = document.components().get(1); + Element resized = document.components().get(2); + Container preview = (Container) render(document, 600, 300); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = new Container(new LayeredLayout()); + canvas.add(preview); + set(builder, "canvasHost", canvas); + Component resizedPreview = componentForElement(preview, resized); + CodenameOneGUIBuilder.ResizePlan plan = new CodenameOneGUIBuilder.ResizePlan( + resizedPreview.getAbsoluteX(), resizedPreview.getAbsoluteY(), + componentForElement(preview, anchor).getWidth(), resizedPreview.getHeight()); + plan.matchWidth = anchor; + + builder.commitGuidedResize(resized, preview, plan, 2); + + assertEquals("fixed", resized.getAttribute("guidedHorizontalSize")); + assertNull(resized.getAttribute("guidedMatchWidth")); + assertEquals(String.valueOf(componentForElement(preview, anchor).getWidth()), + resized.getAttribute("guidedPreferredWidth")); + Container rerendered = (Container) render(document, 800, 300); + assertEquals(componentForElement(rerendered, anchor).getWidth(), + componentForElement(rerendered, resized).getWidth(), 1); + } + + @Test + void resizingASelectedReferencePreviewsAndResizesTheWholeGroupInUnison() throws Exception { + GuiDocument document = document("" + + "" + + "" + + "" + + ""); + Element reference = document.components().get(1); + Element second = document.components().get(2); + Element third = document.components().get(3); + Container preview = (Container) render(document, 760, 520); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(760); canvas.setHeight(520); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component referencePreview = componentForElement(preview, reference); + Component secondPreview = componentForElement(preview, second); + Component thirdPreview = componentForElement(preview, third); + builder.handleDesignerPointerPressed(referencePreview.getAbsoluteX() + referencePreview.getWidth() / 2, + referencePreview.getAbsoluteY() + referencePreview.getHeight() / 2, false); + builder.handleDesignerPointerPressed(secondPreview.getAbsoluteX() + secondPreview.getWidth() / 2, + secondPreview.getAbsoluteY() + secondPreview.getHeight() / 2, true); + builder.handleDesignerPointerPressed(thirdPreview.getAbsoluteX() + thirdPreview.getWidth() / 2, + thirdPreview.getAbsoluteY() + thirdPreview.getHeight() / 2, true); + String originalXml = document.toXml(); + CodenameOneGUIBuilder.ResizePlan plan = new CodenameOneGUIBuilder.ResizePlan( + referencePreview.getAbsoluteX(), referencePreview.getAbsoluteY(), 360, referencePreview.getHeight()); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGuidedResize( + reference, preview, referencePreview, plan, 2); + + assertNotNull(simulation); + assertEquals(originalXml, document.toXml()); + assertEquals(3, simulation.items.stream().filter(item -> item.newW == 360).count(), + "the glass preview must show every selected component at the reference width"); + assertEquals(Component.E_RESIZE_CURSOR, builder.designerResizeCursorAt( + referencePreview.getAbsoluteX() + referencePreview.getWidth(), + referencePreview.getAbsoluteY() + referencePreview.getHeight() / 2)); + assertEquals(Component.SE_RESIZE_CURSOR, builder.designerResizeCursorAt( + referencePreview.getAbsoluteX() + referencePreview.getWidth(), + referencePreview.getAbsoluteY() + referencePreview.getHeight())); + assertEquals(Component.DEFAULT_CURSOR, builder.designerResizeCursorAt( + referencePreview.getAbsoluteX() + referencePreview.getWidth() / 2, + referencePreview.getAbsoluteY() + referencePreview.getHeight() / 2)); + + builder.commitGuidedSelectionResize(reference, preview, plan, 2); + Container resized = (Container) render(document, 760, 520); + assertEquals(360, componentForElement(resized, reference).getWidth(), 1); + assertEquals(360, componentForElement(resized, second).getWidth(), 1); + assertEquals(360, componentForElement(resized, third).getWidth(), 1); + assertEquals("reference", second.getAttribute("guidedMatchWidth")); + assertEquals("reference", third.getAttribute("guidedMatchWidth")); + assertTrue(document.undo()); + assertEquals(originalXml, document.toXml(), "group resize must undo atomically"); + assertFalse(document.canUndo()); + } + + @Test + void resizeSimulationShowsDependentCascadeWithoutMutatingTheDocument() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element primary = document.components().get(1); + Container preview = (Container) render(document, 600, 320); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(600); canvas.setHeight(320); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component primaryPreview = componentForElement(preview, primary); + String originalXml = document.toXml(); + CodenameOneGUIBuilder.ResizePlan plan = new CodenameOneGUIBuilder.ResizePlan( + primaryPreview.getAbsoluteX(), primaryPreview.getAbsoluteY(), 240, primaryPreview.getHeight()); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGuidedResize( + primary, preview, primaryPreview, plan, 2); + + assertNotNull(simulation); + assertEquals(originalXml, document.toXml(), "a live preview must not touch the real document or undo history"); + assertTrue(simulation.changedNames.contains("primary")); + assertTrue(simulation.changedNames.contains("linked"), "the explicit width dependent must be shown changing"); + assertTrue(simulation.links.stream().anyMatch(link -> "primary".equals(link.from) && "linked".equals(link.to))); + com.codename1.guibuilder.ui.DragGuideOverlay.GlassItem linked = simulation.items.stream() + .filter(item -> "linked".equals(item.name)).findFirst().get(); + assertEquals(240, linked.newW, 2); + } + + @Test + void dragSimulationAndCommitProduceTheSameGuidedRectangle() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Element card = document.components().get(1); + Container preview = (Container) render(document, 600, 320); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(600); canvas.setHeight(320); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component source = componentForElement(preview, card); + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = 280; plan.snapY = 150; plan.snapW = source.getWidth(); plan.snapH = source.getHeight(); + plan.horizontalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapX, null, null, null); + plan.verticalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapY, null, null, null); + String originalXml = document.toXml(); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGuidedDrop(card, plan, preview, source); + assertNotNull(simulation); + assertEquals(originalXml, document.toXml()); + com.codename1.guibuilder.ui.DragGuideOverlay.GlassItem predicted = simulation.items.stream() + .filter(item -> "card".equals(item.name)).findFirst().get(); + + assertTrue(builder.applyDropPlan(card, plan, plan.snapX, plan.snapY)); + Container committed = (Container) render(document, 600, 320); + Component committedCard = componentForElement(committed, card); + assertEquals(predicted.newX, committedCard.getAbsoluteX(), 2); + assertEquals(predicted.newY, committedCard.getAbsoluteY(), 2); + assertEquals(predicted.newW, committedCard.getWidth(), 2); + assertEquals(predicted.newH, committedCard.getHeight(), 2); + } + + @Test + void draggingAComponentIntoFreeSpaceTearsAwayItsIncomingRelationships() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element linked = document.components().get(2); + Container preview = (Container) render(document, 700, 400); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(700); canvas.setHeight(400); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component source = componentForElement(preview, linked); + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = 420; plan.snapY = 260; plan.snapW = source.getWidth(); plan.snapH = source.getHeight(); + plan.horizontalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapX, null, null, null); + plan.verticalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapY, null, null, null); + String before = document.toXml(); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGuidedDrop(linked, plan, preview, source); + + assertEquals(before, document.toXml()); + Element simulatedLinked = simulation.document.components().get(2); + assertEquals("fixed", simulatedLinked.getAttribute("guidedHorizontalSize")); + assertNull(simulatedLinked.getAttribute("guidedMatchWidth")); + assertEquals("-|-|-|-", simulatedLinked.getAttribute("guidedReferences")); + assertTrue(simulation.summary.contains("detaches from anchor")); + assertTrue(simulation.links.stream().anyMatch(link -> link.detached + && "anchor".equals(link.from) && "linked".equals(link.to))); + + assertTrue(builder.applyDropPlan(linked, plan, plan.snapX, plan.snapY)); + assertEquals("fixed", linked.getAttribute("guidedHorizontalSize")); + assertNull(linked.getAttribute("guidedMatchWidth")); + assertEquals("-|-|-|-", linked.getAttribute("guidedReferences")); + } + + @Test + void snappingBackToTheSameReferenceKeepsTheExplicitRelationship() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element anchor = document.components().get(1); + Element linked = document.components().get(2); + Container preview = (Container) render(document, 700, 400); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(700); canvas.setHeight(400); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component source = componentForElement(preview, linked); + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = componentForElement(preview, anchor).getAbsoluteX(); + plan.snapY = 220; plan.snapW = source.getWidth(); plan.snapH = source.getHeight(); + plan.horizontalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapX, "align start", anchor, "alignStart"); + plan.verticalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapY, null, null, null); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGuidedDrop(linked, plan, preview, source); + Element simulatedLinked = simulation.document.components().get(2); + assertEquals("match", simulatedLinked.getAttribute("guidedHorizontalSize")); + assertEquals("anchor", simulatedLinked.getAttribute("guidedMatchWidth")); + assertFalse(simulation.links.stream().anyMatch(link -> link.detached)); + } + + @Test + void movingSameWidthBelowItsDependentActionRebasesTheCycleWithoutBouncingAnything() throws Exception { + GuiDocument document = document("" + + "" + + "" + + "" + + "" + + ""); + Element secondary = document.components().get(2); + Element baselineLabel = document.components().get(3); + Element action = document.components().get(4); + Container preview = (Container) render(document, 900, 600); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(900); canvas.setHeight(600); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component source = componentForElement(preview, secondary); + Component labelPreview = componentForElement(preview, baselineLabel); + Component actionPreview = componentForElement(preview, action); + int[] oldSource = bounds(source); + int[] oldLabel = bounds(labelPreview); + int[] oldAction = bounds(actionPreview); + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = action; plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = actionPreview.getAbsoluteX() + actionPreview.getWidth() - source.getWidth(); + plan.snapY = actionPreview.getAbsoluteY() + actionPreview.getHeight() + 12; + plan.snapW = source.getWidth(); plan.snapH = source.getHeight(); + plan.horizontalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapX, "align end with Action", action, "alignEnd"); + plan.verticalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapY, "space after Action", action, "after"); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGuidedDrop(secondary, plan, preview, source); + Container proposed = (Container) render(simulation.document, 900, 600); + Component proposedSecondary = componentForElement(proposed, simulation.document.components().get(2)); + Component proposedLabel = componentForElement(proposed, simulation.document.components().get(3)); + Component proposedAction = componentForElement(proposed, simulation.document.components().get(4)); + + assertEquals(oldSource[2], proposedSecondary.getWidth(), 1, + "a position drag must preserve width: " + simulation.document.toXml()); + assertEquals(oldSource[3], proposedSecondary.getHeight(), 1, "a position drag must preserve height"); + assertEquals(oldAction[0] + oldAction[2], proposedSecondary.getX() + proposedSecondary.getWidth(), 1, + "the selected right-edge rule must be the rendered result; source margins=" + + source.getStyle().getMarginLeftNoRTL() + "," + source.getStyle().getMarginRightNoRTL() + + " target margins=" + actionPreview.getStyle().getMarginLeftNoRTL() + "," + + actionPreview.getStyle().getMarginRightNoRTL()); + assertTrue(proposedSecondary.getY() >= oldAction[1] + oldAction[3], + "the selected below-Action rule must be the rendered result"); + assertArrayEquals(oldLabel, bounds(proposedLabel), "the baseline label must be rebased in place"); + assertArrayEquals(oldAction, bounds(proposedAction), "Action must not bounce when it becomes the new anchor"); + assertFalse(simulation.changedNames.contains("baselineLabel")); + assertFalse(simulation.changedNames.contains("baselineAction")); + assertTrue(simulation.summary.contains("keeps in place baselineLabel from secondary")); + assertTrue(simulation.links.stream().anyMatch(link -> link.detached + && "secondary".equals(link.from) && "baselineLabel".equals(link.to))); + + assertTrue(builder.applyDropPlan(secondary, plan, plan.snapX, plan.snapY)); + Container committed = (Container) render(document, 900, 600); + assertArrayEquals(bounds(proposedSecondary), bounds(componentForElement(committed, secondary))); + assertArrayEquals(oldLabel, bounds(componentForElement(committed, baselineLabel))); + assertArrayEquals(oldAction, bounds(componentForElement(committed, action))); + + Container wider = (Container) render(document, 1200, 700); + Component widerSecondary = componentForElement(wider, secondary); + Component widerAction = componentForElement(wider, action); + assertEquals(widerAction.getX() + widerAction.getWidth(), + widerSecondary.getX() + widerSecondary.getWidth(), 1); + assertTrue(widerSecondary.getY() >= widerAction.getY() + widerAction.getHeight()); + } + + @Test + void aligningPrimaryWithItsTransitiveDependentPreservesEveryUnaffectedRenderedRectangle() throws Exception { + GuiDocument document = guidedChainDocument(); + Element primary = document.components().get(1); + Element secondary = document.components().get(2); + Element baselineLabel = document.components().get(3); + Element baselineAction = document.components().get(4); + Container preview = (Container) render(document, 900, 700); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(900); canvas.setHeight(700); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + + Component primaryPreview = componentForElement(preview, primary); + Component secondaryPreview = componentForElement(preview, secondary); + Component labelPreview = componentForElement(preview, baselineLabel); + Component actionPreview = componentForElement(preview, baselineAction); + // Reproduce the live theme geometry: the relationship currently renders Secondary + // taller than its nominal preferred height, and the downstream chain follows it. + secondaryPreview.setHeight(secondaryPreview.getHeight() + 40); + labelPreview.setY(labelPreview.getY() + 40); + actionPreview.setY(actionPreview.getY() + 40); + int[] oldPrimary = bounds(primaryPreview); + int[] oldSecondary = bounds(secondaryPreview); + int[] oldLabel = bounds(labelPreview); + int[] oldAction = bounds(actionPreview); + + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = document.root(); plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = actionPreview.getAbsoluteX(); + plan.snapY = actionPreview.getAbsoluteY() + actionPreview.getHeight() + 80; + plan.snapW = primaryPreview.getWidth(); plan.snapH = primaryPreview.getHeight(); + plan.horizontalSnap = new CodenameOneGUIBuilder.SnapResult( + plan.snapX, "align start with baselineAction", baselineAction, "alignStart"); + plan.verticalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapY, null, null, null); + + CodenameOneGUIBuilder.GuidedSimulation simulation = + builder.simulateGuidedDrop(primary, plan, preview, primaryPreview); + assertNotNull(simulation); + Container proposed = (Container) render(simulation.document, 900, 700); + Component proposedPrimary = componentForElement(proposed, simulation.document.components().get(1)); + Component proposedSecondary = componentForElement(proposed, simulation.document.components().get(2)); + Component proposedLabel = componentForElement(proposed, simulation.document.components().get(3)); + Component proposedAction = componentForElement(proposed, simulation.document.components().get(4)); + + assertEquals(oldPrimary[2], proposedPrimary.getWidth(), 1, "position-only drag changed Primary width"); + assertEquals(oldPrimary[3], proposedPrimary.getHeight(), 1, "position-only drag changed Primary height"); + assertArrayEquals(oldSecondary, bounds(proposedSecondary), "cycle rebase resized Secondary"); + assertArrayEquals(oldLabel, bounds(proposedLabel), "Secondary resize moved Baseline Label"); + assertArrayEquals(oldAction, bounds(proposedAction), "Secondary resize moved Action"); + assertFalse(simulation.changedNames.contains("secondary")); + assertFalse(simulation.changedNames.contains("baselineLabel")); + assertFalse(simulation.changedNames.contains("baselineAction")); + + assertTrue(builder.applyDropPlan(primary, plan, plan.snapX, plan.snapY)); + Container committed = (Container) render(document, 900, 700); + assertArrayEquals(bounds(proposedPrimary), bounds(componentForElement(committed, primary))); + assertArrayEquals(oldSecondary, bounds(componentForElement(committed, secondary))); + assertArrayEquals(oldLabel, bounds(componentForElement(committed, baselineLabel))); + assertArrayEquals(oldAction, bounds(componentForElement(committed, baselineAction))); + } + + @Test + void movingPrimaryBelowSameWidthKeepsTheEntireDownstreamChainVisible() throws Exception { + assertPrimarySecondaryPlacementKeepsEverythingVisible(true); + } + + @Test + void movingSameWidthBelowPrimaryKeepsTheEntireDownstreamChainVisible() throws Exception { + assertPrimarySecondaryPlacementKeepsEverythingVisible(false); + } + + private void assertPrimarySecondaryPlacementKeepsEverythingVisible(boolean movePrimary) throws Exception { + GuiDocument document = guidedChainDocument(); + Element primary = document.components().get(1); + Element secondary = document.components().get(2); + Element dragged = movePrimary ? primary : secondary; + Element anchor = movePrimary ? secondary : primary; + Container preview = (Container) render(document, 900, 600); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(900); canvas.setHeight(600); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + Component source = componentForElement(preview, dragged); + Component anchorPreview = componentForElement(preview, anchor); + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; plan.target = anchor; plan.parent = document.root(); + plan.layout = "LayeredLayout"; plan.valid = true; + plan.snapX = anchorPreview.getAbsoluteX(); + plan.snapY = anchorPreview.getAbsoluteY() + anchorPreview.getHeight() + 8; + plan.snapW = source.getWidth(); plan.snapH = source.getHeight(); + plan.horizontalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapX, "align start", anchor, "alignStart"); + plan.verticalSnap = new CodenameOneGUIBuilder.SnapResult(plan.snapY, "space after", anchor, "after"); + + CodenameOneGUIBuilder.GuidedSimulation simulation = builder.simulateGuidedDrop(dragged, plan, preview, source); + assertNotNull(simulation); + Container proposed = (Container) render(simulation.document, 900, 600); + assertAllComponentsVisible(simulation.document, proposed); + + assertTrue(builder.applyDropPlan(dragged, plan, plan.snapX, plan.snapY)); + for (int i = 0; i < document.components().size(); i++) { + assertEquals(simulation.document.components().get(i).getAttribute("name"), + document.components().get(i).getAttribute("name"), + "a positional guided drag must not silently change stacking order"); + } + Container committed = (Container) render(document, 900, 600); + assertAllComponentsVisible(document, committed); + for (int i = 1; i < document.components().size(); i++) { + Component expected = componentForElement(proposed, simulation.document.components().get(i)); + Component actual = componentForElement(committed, document.components().get(i)); + assertArrayEquals(bounds(expected), bounds(actual), document.components().get(i).getAttribute("name")); + } + } + + private static GuiDocument guidedChainDocument() { + return document("" + + "" + + "" + + "" + + "" + + ""); + } + + private static void assertAllComponentsVisible(GuiDocument document, Container rendered) { + for (int i = 1; i < document.components().size(); i++) { + Element element = document.components().get(i); + Component component = componentForElement(rendered, element); + assertNotNull(component, element.getAttribute("name") + " must still render"); + assertTrue(component.isVisible(), element.getAttribute("name") + " must remain visible"); + assertTrue(component.getWidth() > 1 && component.getHeight() > 1, + element.getAttribute("name") + " collapsed to " + java.util.Arrays.toString(bounds(component))); + assertTrue(component.getAbsoluteX() + component.getWidth() > rendered.getAbsoluteX() + && component.getAbsoluteY() + component.getHeight() > rendered.getAbsoluteY() + && component.getAbsoluteX() < rendered.getAbsoluteX() + rendered.getWidth() + && component.getAbsoluteY() < rendered.getAbsoluteY() + rendered.getHeight(), + element.getAttribute("name") + " moved outside the surface: " + java.util.Arrays.toString(bounds(component))); + } + } + + @Test + void borderReplacementSwapsConstraintsAndSurvivesSerialization() throws Exception { + GuiDocument document = document(""); + Element west = document.components().get(1); + Element center = document.components().get(2); + CodenameOneGUIBuilder builder = builder(document); + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; + plan.target = center; + plan.parent = document.root(); + plan.occupied = center; + plan.layout = "BorderLayout"; + plan.constraint = "Center"; + plan.valid = true; + + assertTrue(builder.applyDropPlan(west, plan, 0, 0)); + assertEquals("Center", west.getAttribute("layoutConstraint"), document.toXml()); + assertEquals("West", center.getAttribute("layoutConstraint")); + GuiDocument reparsed = GuiDocument.parse("Form.gui", document.toXml()); + assertEquals("Center", reparsed.components().get(1).getAttribute("layoutConstraint")); + assertEquals("West", reparsed.components().get(2).getAttribute("layoutConstraint")); + + Component rendered = ComponentPreviewFactory.create(reparsed.root(), null, handler()); + rendered.setWidth(600); + rendered.setHeight(400); + ((Container) rendered).layoutContainer(); + Component renderedWestButton = componentForElement((Container) rendered, reparsed.components().get(1)); + Component renderedFormerCenter = componentForElement((Container) rendered, reparsed.components().get(2)); + assertNotNull(renderedWestButton); + assertNotNull(renderedFormerCenter); + assertTrue(renderedWestButton.isVisible() && renderedWestButton.getWidth() > 0 && renderedWestButton.getHeight() > 0, + "the component moved into Center must not vanish"); + assertTrue(renderedFormerCenter.isVisible() && renderedFormerCenter.getWidth() > 0 && renderedFormerCenter.getHeight() > 0, + "the displaced Center component must remain visible in West"); + BorderLayout renderedLayout = (BorderLayout) ((Container) rendered).getLayout(); + assertSame(renderedWestButton, renderedLayout.getCenter(), "the dragged WEST component must own CENTER"); + assertSame(renderedFormerCenter, renderedLayout.getWest(), "the displaced CENTER component must own WEST"); + assertTrue(renderedFormerCenter.getX() + renderedFormerCenter.getWidth() <= renderedWestButton.getX(), + "Border regions must be adjacent, never stacked behind each other"); + assertTrue(renderedWestButton.getWidth() >= 100, + "a long component displaced into WEST must be capped so CENTER cannot vanish"); + } + + @Test + void inlineEditorTeardownCommitsModelAndVisiblePreview() throws Exception { + GuiDocument document = document(""); + Element title = document.components().get(1); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = new Container(); + Label preview = new Label("Old"); + preview.putClientProperty("gui.element", title); + canvas.add(preview); + set(builder, "canvasHost", canvas); + + builder.commitInlineValue(title, "text", "Edited in place"); + + assertEquals("Edited in place", title.getAttribute("text")); + assertEquals("Edited in place", preview.getText()); + assertTrue(document.isModified()); + } + + @Test + void everySupportedLayoutUsesAnExplicitPlacementAdapter() throws Exception { + CodenameOneGUIBuilder builder = builder(document("")); + assertEquals("BorderPlacementAdapter", builder.placementAdapterName("BorderLayout")); + assertEquals("LayeredPlacementAdapter", builder.placementAdapterName("LayeredLayout")); + assertEquals("BoxPlacementAdapter", builder.placementAdapterName("BoxLayout")); + assertEquals("GridPlacementAdapter", builder.placementAdapterName("GridLayout")); + assertEquals("TablePlacementAdapter", builder.placementAdapterName("TableLayout")); + assertEquals("FlowPlacementAdapter", builder.placementAdapterName("FlowLayout")); + } + + @Test + void borderCenterCannotConsumeReachableEastAndWestDropBands() { + assertEquals("West", CodenameOneGUIBuilder.borderEdgeRegion(100, 100, 600, 500, 90, 40, 175, 350)); + assertEquals("East", CodenameOneGUIBuilder.borderEdgeRegion(100, 100, 600, 500, 90, 40, 625, 350)); + assertNull(CodenameOneGUIBuilder.borderEdgeRegion(100, 100, 600, 500, 90, 40, 400, 350)); + assertEquals("North", CodenameOneGUIBuilder.borderEdgeRegion(100, 100, 600, 500, 90, 40, 400, 120)); + assertEquals("South", CodenameOneGUIBuilder.borderEdgeRegion(100, 100, 600, 500, 90, 40, 400, 580)); + } + + @Test + void numericLayoutValuesRejectNonNumbersAndOverflow() { + assertNull(CodenameOneGUIBuilder.parseInteger("three")); + assertNull(CodenameOneGUIBuilder.parseInteger("2.5")); + assertNull(CodenameOneGUIBuilder.parseInteger("")); + assertEquals(-1, CodenameOneGUIBuilder.parseInteger("-1")); + assertEquals(12, CodenameOneGUIBuilder.parseInteger("12")); + } + + @Test + void boxLayoutXDropReordersAndRendersInHorizontalOrder() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Element a = document.components().get(1); + Element c = document.components().get(3); + CodenameOneGUIBuilder builder = builder(document); + assertTrue(builder.applyDropPlan(c, sequentialPlan(document, document.root(), a, "BoxLayout", false), 0, 0)); + assertSame(c, document.root().getChildAt(0)); + Component rendered = render(document, 600, 180); + Component renderedC = componentForElement((Container) rendered, c); + Component renderedA = componentForElement((Container) rendered, a); + assertTrue(renderedC.getX() < renderedA.getX()); + } + + @Test + void gridLayoutDropReordersWithoutOverlappingOrLosingCells() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Element a = document.components().get(1); + Element d = document.components().get(4); + CodenameOneGUIBuilder builder = builder(document); + assertTrue(builder.applyDropPlan(d, sequentialPlan(document, document.root(), a, "GridLayout", false), 0, 0)); + Component rendered = render(document, 600, 400); + java.util.Set cells = new java.util.HashSet<>(); + for (int i = 1; i < document.components().size(); i++) { + Component component = componentForElement((Container) rendered, document.components().get(i)); + assertTrue(component.getWidth() > 0 && component.getHeight() > 0); + cells.add(component.getX() + ":" + component.getY()); + } + assertEquals(4, cells.size()); + } + + @Test + void tableLayoutDropAssignsUniqueCellsAndExpandsRowsInsteadOfHidingOverflow() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Element a = document.components().get(1); + Element c = document.components().get(3); + CodenameOneGUIBuilder builder = builder(document); + assertTrue(builder.applyDropPlan(c, sequentialPlan(document, document.root(), a, "TableLayout", false), 0, 0)); + assertEquals("2", document.root().getAttribute("tableLayoutRows")); + java.util.Set modelCells = new java.util.HashSet<>(); + for (int i = 1; i < document.components().size(); i++) { + Element child = document.components().get(i); + modelCells.add(child.getAttribute("tableRow") + ":" + child.getAttribute("tableColumn")); + } + assertEquals(3, modelCells.size()); + Component rendered = render(document, 600, 400); + for (int i = 1; i < document.components().size(); i++) { + Component component = componentForElement((Container) rendered, document.components().get(i)); + assertTrue(component.isVisible() && component.getWidth() > 0 && component.getHeight() > 0); + } + } + + @Test + void tableMoveEarlierReassignsCellsAndChangesRenderedOrder() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element b = document.components().get(2); + Element c = document.components().get(3); + document.select(c); + CodenameOneGUIBuilder builder = builder(document); + + assertTrue(builder.reorderSelectedInParent(-1)); + assertEquals("1", c.getAttribute("tableColumn")); + assertEquals("2", b.getAttribute("tableColumn")); + Component rendered = render(document, 600, 200); + assertTrue(componentForElement((Container) rendered, c).getX() + < componentForElement((Container) rendered, b).getX()); + } + + @Test + void aTableDropLandsInTheCellUnderThePointerAndLeavesEveryOtherCellAlone() throws Exception { + GuiDocument document = document("" + + "" + + "" + + ""); + Element a = document.components().get(1); + Element b = document.components().get(2); + Element c = document.components().get(3); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = canvasFor(document, builder, 400, 400); + + // Drop c into the empty cell (row 1, column 1): the lower right quadrant. + Component source = componentForElement(canvas, c); + CodenameOneGUIBuilder.DropPlan plan = builder.planDrop(c, document.root(), source, 300, 300); + assertNotNull(plan); + assertTrue(builder.applyDropPlan(c, plan, 300, 300)); + + assertEquals("1", c.getAttribute("tableRow"), document.toXml()); + assertEquals("1", c.getAttribute("tableColumn"), document.toXml()); + assertEquals("0:0", a.getAttribute("tableRow") + ":" + a.getAttribute("tableColumn"), + "a was not dragged, so its cell must not move: " + document.toXml()); + assertEquals("0:1", b.getAttribute("tableRow") + ":" + b.getAttribute("tableColumn"), + "b was not dragged, so its cell must not move: " + document.toXml()); + } + + @Test + void aTableDropNeverStacksTwoComponentsInOneCell() throws Exception { + GuiDocument document = document("" + + "" + + "" + + "" + + ""); + Element a = document.components().get(1); + Element d = document.components().get(4); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = canvasFor(document, builder, 400, 400); + + // Drop d onto a's occupied cell: the two must swap, not overlap. + Component source = componentForElement(canvas, d); + CodenameOneGUIBuilder.DropPlan plan = builder.planDrop(d, document.root(), source, 100, 100); + assertNotNull(plan); + assertTrue(builder.applyDropPlan(d, plan, 100, 100)); + + assertEquals("0:0", d.getAttribute("tableRow") + ":" + d.getAttribute("tableColumn"), document.toXml()); + assertEquals("1:1", a.getAttribute("tableRow") + ":" + a.getAttribute("tableColumn"), + "the displaced component must take the vacated cell: " + document.toXml()); + assertEquals(4, distinctTableCells(document).size(), document.toXml()); + } + + @Test + void aComponentDraggedBetweenTwoNestedContainersReparentsAndRenders() throws Exception { + GuiDocument document = nestedColumnsDocument(); + Element leftGrid = document.components().get(2); + Element rightActions = document.components().get(7); + Element nestedA = document.components().get(3); + Element nestedAction = document.components().get(9); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = canvasFor(document, builder, 700, 500); + + Component target = componentForElement(canvas, nestedAction); + assertNotNull(target, "the demo must render the right hand column"); + Component source = componentForElement(canvas, nestedA); + int x = target.getAbsoluteX() + target.getWidth() / 2; + int y = target.getAbsoluteY() + target.getHeight() - 2; + + CodenameOneGUIBuilder.DropPlan plan = builder.planDrop(nestedA, builder.elementAt(canvas, x, y), source, x, y); + assertNotNull(plan, "hovering the far column must produce a drop plan"); + assertSame(rightActions, plan.parent, "the drop must target the hovered container, not the drag source's"); + assertTrue(builder.applyDropPlan(nestedA, plan, x, y), document.toXml()); + + assertSame(rightActions, document.parentOf(nestedA), document.toXml()); + assertEquals(3, document.componentsIn(leftGrid).size(), + "the source container must actually give the child up: " + document.toXml()); + Container reRendered = canvasFor(document, builder, 700, 500); + Component moved = componentForElement(reRendered, nestedA); + assertNotNull(moved, "the moved component must still render: " + document.toXml()); + assertTrue(moved.getWidth() > 0 && moved.getHeight() > 0, + "the moved component must not collapse to zero size: " + document.toXml()); + Component column = componentForElement(reRendered, rightActions); + assertTrue(moved.getAbsoluteX() >= column.getAbsoluteX() + && moved.getAbsoluteX() < column.getAbsoluteX() + column.getWidth(), + "the moved component must render inside its new parent"); + } + + @Test + void aNestedContainerCanBeDraggedWholeWithoutLosingItsChildren() throws Exception { + GuiDocument document = nestedColumnsDocument(); + Element contentColumns = document.components().get(1); + Element leftGrid = document.components().get(2); + Element rightActions = document.components().get(7); + CodenameOneGUIBuilder builder = builder(document); + Container canvas = canvasFor(document, builder, 700, 500); + + Component source = componentForElement(canvas, leftGrid); + Component target = componentForElement(canvas, rightActions); + int x = target.getAbsoluteX() + target.getWidth() - 2; + int y = target.getAbsoluteY() + target.getHeight() / 2; + CodenameOneGUIBuilder.DropPlan plan = builder.planDrop(leftGrid, rightActions, source, x, y); + assertNotNull(plan); + assertTrue(builder.applyDropPlan(leftGrid, plan, x, y), document.toXml()); + + assertEquals(4, document.componentsIn(leftGrid).size(), + "moving a container must carry its whole subtree: " + document.toXml()); + assertNotSame(contentColumns, document.parentOf(leftGrid)); + Container reRendered = canvasFor(document, builder, 700, 500); + for (Element child : document.componentsIn(leftGrid)) { + Component preview = componentForElement(reRendered, child); + assertNotNull(preview, child.getAttribute("name") + " vanished: " + document.toXml()); + } + } + + @Test + void undoAfterAReparentingDragRestoresTheExactTreeAndOrder() throws Exception { + GuiDocument document = nestedColumnsDocument(); + String before = document.toXml(); + Element leftGrid = document.components().get(2); + Element rightActions = document.components().get(7); + Element nestedA = document.components().get(3); + CodenameOneGUIBuilder builder = builder(document); + canvasFor(document, builder, 700, 500); + + assertTrue(builder.applyDropPlan(nestedA, + sequentialPlan(document, rightActions, rightActions, "BoxLayout", true), 0, 0)); + assertSame(rightActions, document.parentOf(nestedA)); + assertTrue(document.undo()); + + assertEquals(before, document.toXml(), "undo must restore the document byte for byte"); + List grid = GuiDocument.componentsIn(findByName(document, "leftGrid")); + assertEquals(Arrays.asList("nestedA", "nestedB", "nestedC", "nestedD"), namesOf(grid), + "undo must restore sibling order, not just membership"); + } + + @Test + void repeatedEditAndUndoCyclesNeverDriftTheDocument() throws Exception { + GuiDocument document = nestedColumnsDocument(); + String before = document.toXml(); + CodenameOneGUIBuilder builder = builder(document); + for (int i = 0; i < 4; i++) { + canvasFor(document, builder, 700, 500); + Element rightActions = findByName(document, "rightActions"); + Element nestedA = findByName(document, "nestedA"); + assertTrue(builder.applyDropPlan(nestedA, + sequentialPlan(document, rightActions, rightActions, "BoxLayout", true), 0, 0), + "cycle " + i + " could not move the component"); + assertTrue(document.undo(), "cycle " + i + " could not undo"); + assertEquals(before, document.toXml(), "the document drifted on cycle " + i); + } + } + + private static Element findByName(GuiDocument document, String name) { + for (Element element : document.components()) { + if (name.equals(element.getAttribute("name"))) return element; + } + return null; + } + + private static List namesOf(List elements) { + List names = new java.util.ArrayList<>(); + for (Element element : elements) names.add(element.getAttribute("name")); + return names; + } + + private static String value(Element element, String attribute, String fallback) { + String raw = element == null ? null : element.getAttribute(attribute); + return raw == null ? fallback : raw; + } + + /** + * Indented exactly like the .gui files the editor loads from disk. The indentation is not + * cosmetic: XMLParser turns it into whitespace text nodes, so a document read from a project + * has interleaved non-component children that a document built from one long line does not. + */ + private static GuiDocument nestedColumnsDocument() { + return document("\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""); + } + + private static java.util.Set distinctTableCells(GuiDocument document) { + java.util.Set cells = new java.util.HashSet<>(); + for (int i = 1; i < document.components().size(); i++) { + Element child = document.components().get(i); + cells.add(child.getAttribute("tableRow") + ":" + child.getAttribute("tableColumn")); + } + return cells; + } + + private static Container canvasFor(GuiDocument document, CodenameOneGUIBuilder builder, int width, int height) + throws Exception { + Container preview = (Container) render(document, width, height); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(width); + canvas.setHeight(height); + canvas.add(preview); + canvas.layoutContainer(); + layoutNested(canvas); + set(builder, "canvasHost", canvas); + return canvas; + } + + @Test + void nestedGridPlacementMovesOnlyTheSelectedBranch() throws Exception { + GuiDocument document = document("" + + ""); + Element grid = document.components().get(1); + Element inside = document.components().get(2); + Element outside = document.components().get(3); + CodenameOneGUIBuilder builder = builder(document); + assertTrue(builder.applyDropPlan(outside, sequentialPlan(document, grid, inside, "GridLayout", true), 0, 0)); + assertSame(grid, document.parentOf(outside)); + assertSame(document.root(), document.parentOf(grid)); + Component rendered = render(document, 600, 400); + Component outsidePreview = componentForElement((Container) rendered, outside); + assertNotNull(outsidePreview); + assertTrue(outsidePreview.getWidth() > 0 && outsidePreview.getHeight() > 0); + } + + private static CodenameOneGUIBuilder builder(GuiDocument document) throws Exception { + CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + set(builder, "document", document); + return builder; + } + + @Test + void staleElementFromAnotherFormCannotEnterTheActiveDocument() throws Exception { + GuiDocument oldForm = document(""); + GuiDocument activeForm = document(""); + Element stale = oldForm.components().get(1); + Element target = activeForm.components().get(1); + CodenameOneGUIBuilder builder = builder(activeForm); + String before = activeForm.toXml(); + CodenameOneGUIBuilder.DropPlan forged = sequentialPlan(activeForm, activeForm.root(), target, "BoxLayout", true); + + assertFalse(builder.applyDropPlan(stale, forged, 0, 0)); + assertEquals(before, activeForm.toXml()); + assertFalse(builder.isActiveDocumentElement(stale)); + assertTrue(builder.isActiveDocumentElement(target)); + } + + @Test + void boxXPreviewIsActuallyScrollableAndDragAtRightEdgeAdvancesIt() throws Exception { + GuiDocument document = document("" + + "" + + "" + + "" + + ""); + Container preview = (Container) render(document, 280, 160); + assertTrue(preview.isScrollableX(), "the sample must overflow horizontally"); + Container canvas = new Container(new BorderLayout()); + canvas.setWidth(280); + canvas.setHeight(160); + canvas.add(BorderLayout.CENTER, preview); + canvas.layoutContainer(); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + + builder.autoScrollDuringDrag(preview.getAbsoluteX() + preview.getWidth() - 2, + preview.getAbsoluteY() + preview.getHeight() / 2); + + assertTrue(preview.getScrollX() > 0, "holding a drag at the right edge must reveal later siblings"); + } + + @Test + void previewComponentsExposeStableAccessibilityIdentifiers() { + GuiDocument document = document("" + + "" + + ""); + Container preview = (Container) render(document, 600, 400); + Component button = componentForElement(preview, document.components().get(1)); + + assertNotNull(button); + assertEquals("guibuilder.preview.LoginForm", preview.getSemantics().getIdentifier()); + assertEquals("guibuilder.preview.primaryAction", button.getSemantics().getIdentifier()); + assertTrue(button.getSemantics().getLabel().contains("primaryAction")); + } + + @Test + void mcpPublishesStructuredDesignerStateAndLiveActions() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Container preview = (Container) render(document, 640, 420); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(640); canvas.setHeight(420); + canvas.getAllStyles().setPadding(11, 13, 17, 19); + canvas.add(preview); + ((LayeredLayout) canvas.getLayout()).setInsets(preview, "0 0 0 0"); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "binding", ProjectBinding.parse("projectDir=/tmp/project\nguiDir=/tmp/project/src/main/guibuilder")); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + set(builder, "guiFiles", new java.util.ArrayList(java.util.Arrays.asList("Form.gui"))); + com.codename1.guibuilder.ui.DragGuideOverlay overlay = + new com.codename1.guibuilder.ui.DragGuideOverlay(); + canvas.add(overlay); + ((LayeredLayout) canvas.getLayout()).setInsets(overlay, "0 0 0 0"); + canvas.layoutContainer(); + document.select(document.components().get(1)); + Component selectedPreview = componentForElement(preview, document.components().get(1)); + overlay.showSelection(selectedPreview); + int[] paintCoordinates = overlay.selectionPaintLocalBounds(); + assertEquals(selectedPreview.getAbsoluteX() - canvas.getAbsoluteX(), paintCoordinates[0], + "selection X must be expressed in the parent Graphics coordinate system"); + assertEquals(selectedPreview.getAbsoluteY() - canvas.getAbsoluteY(), paintCoordinates[1], + "selection Y must be expressed in the parent Graphics coordinate system"); + assertTrue(overlay.getX() > 0 || overlay.getY() > 0, + "the regression needs a padded parent so overlay-local and paint coordinates differ"); + set(builder, "dragGuideOverlay", overlay); + GuiBuilderMcpController controller = new GuiBuilderMcpController(builder); + set(builder, "mcpController", controller); + controller.register(); + controller.record("test_action", java.util.Collections.singletonMap("detail", "live")); + + Map state = builder.mcpState(controller.latestSequence()); + assertEquals("Form", state.get("activeForm")); + assertEquals("primary", state.get("selected")); + List components = (List) state.get("components"); + assertEquals(2, components.size()); + assertEquals("primary", ((Map) components.get(1)).get("name")); + assertEquals("guibuilder.preview.primary", + ((Map) components.get(1)).get("accessibilityIdentifier")); + assertEquals(((Map) components.get(1)).get("bounds"), state.get("selectionPaintBounds"), + "MCP must expose the exact absolute pixels painted by the selection overlay"); + + String tools = MCP.getServer().handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + assertTrue(tools.contains("guibuilder_state")); + assertTrue(tools.contains("guibuilder_drag")); + assertTrue(tools.contains("guibuilder_actions")); + String actions = MCP.getServer().handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{" + + "\"name\":\"guibuilder_actions\",\"arguments\":{\"afterSequence\":0}}}"); + assertTrue(actions.contains("test_action")); + assertTrue(actions.contains("live")); + } + + @Test + void mcpAdditiveSelectionUsesJsonBooleansAndPublishesTheWholeGroup() throws Exception { + GuiDocument document = document("" + + "" + + ""); + Container preview = (Container) render(document, 640, 420); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(640); canvas.setHeight(420); canvas.add(preview); + CodenameOneGUIBuilder builder = builder(document); + set(builder, "canvasHost", canvas); + set(builder, "previewRoot", preview); + GuiBuilderMcpController controller = new GuiBuilderMcpController(builder); + set(builder, "mcpController", controller); + controller.register(); + + MCP.getServer().handleMessage("{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\",\"params\":{" + + "\"name\":\"guibuilder_select\",\"arguments\":{\"component\":\"first\"}}}"); + String additive = MCP.getServer().handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\",\"params\":{" + + "\"name\":\"guibuilder_select\",\"arguments\":{\"component\":\"second\",\"additive\":true}}}"); + + assertTrue(additive.contains("\\\"selectedComponents\\\":[\\\"first\\\",\\\"second\\\"]"), additive); + } + + private static CodenameOneGUIBuilder.DropPlan sequentialPlan(GuiDocument document, Element parent, Element target, String layout, boolean after) { + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; + plan.parent = parent; + plan.target = target; + plan.layout = layout; + plan.after = after; + plan.valid = true; + return plan; + } + + private static Component render(GuiDocument document, int width, int height) { + Component rendered = ComponentPreviewFactory.create(document.root(), null, handler()); + rendered.setWidth(width); + rendered.setHeight(height); + ((Container) rendered).layoutContainer(); + layoutNested((Container) rendered); + return rendered; + } + + private static void layoutNested(Container container) { + container.layoutContainer(); + for (int i = 0; i < container.getComponentCount(); i++) { + if (container.getComponentAt(i) instanceof Container) layoutNested((Container) container.getComponentAt(i)); + } + } + + private static GuiDocument document(String xml) { + return GuiDocument.parse("Form.gui", xml); + } + + private static void set(Object target, String name, Object value) throws Exception { + Field field = CodenameOneGUIBuilder.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static ComponentPreviewFactory.SelectionHandler handler() { + return new ComponentPreviewFactory.SelectionHandler() { + public void selected(Element element) { } + public void dragPressed(Element element, Component source, int x, int y) { } + public boolean isDragActive() { return false; } + public void editContent(Element element) { } + }; + } + + private static Component componentForElement(Container root, Element element) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component.getClientProperty("gui.element") == element) return component; + if (component instanceof Container) { + Component nested = componentForElement((Container) component, element); + if (nested != null) return nested; + } + } + return null; + } + + private static int[] bounds(Component component) { + return new int[]{component.getAbsoluteX(), component.getAbsoluteY(), component.getWidth(), component.getHeight()}; + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java new file mode 100644 index 00000000000..3e5615f0ec8 --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -0,0 +1,358 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.guibuilder.model.GuiDocument; +import com.codename1.guibuilder.project.ProjectBinding; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.JPanel; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class GeneratedSourceTest { + /** + * Constructing the builder creates a UIManager, which needs a Display. Relying on another test + * class to have initialized it first passes locally and fails wherever the runner happens to + * order this class first. + */ + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!com.codename1.ui.Display.isInitialized()) com.codename1.ui.Display.init(new JPanel()); + } + + @Test + void generatedFormAndBindingModelCompileTogether() throws Exception { + CodenameOneGUIBuilder builder = builder("properties"); + + String form = invoke(builder, "defaultCompanionSource"); + String model = invoke(builder, "generatedModelSource"); + assertTrue(form.contains("extends Form")); + assertTrue(form.contains("new UiBinding().bind(model, this)")); + assertTrue(model.contains("implements PropertyBusinessObject")); + assertTrue(form.contains("// ")); + assertTrue(form.contains("// ")); + + compile(form, model); + } + + @Test + void bindablePojoStrategyGeneratesAnnotationBinderSource() throws Exception { + CodenameOneGUIBuilder builder = builder("bindable"); + String form = invoke(builder, "defaultCompanionSource"); + String model = invoke(builder, "generatedModelSource"); + assertTrue(form.contains("Binders.bind(model, this)")); + assertTrue(model.contains("@Bindable")); + assertTrue(model.contains("@Bind(name = \"email\", attr = BindAttr.TEXT)")); + assertTrue(model.contains("@Bind(name = \"remember\", attr = BindAttr.SELECTED)")); + compile(form, model); + } + + @Test + void noBindingStrategyHasNoModelDependency() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + String form = invoke(builder, "defaultCompanionSource"); + assertFalse(form.contains("LoginFormModel model")); + assertFalse(form.contains("UiBinding")); + compile(form, null); + } + + @Test + void guidedConstraintsAndLaterSiblingReferencesGenerateCompilableSource() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + set(builder, "document", GuiDocument.parse("/tmp/project/gui/com/example/LoginForm.gui", + "" + + "" + + "" + + "")); + + String form = invoke(builder, "defaultCompanionSource"); + + assertTrue(form.contains("setReferenceComponents(linked, anchor, anchor, null, anchor)")); + assertTrue(form.contains("size.setWidth(140)")); + assertFalse(form.contains("setPreferredW")); + assertFalse(form.contains("setPreferredH")); + assertTrue(form.indexOf("anchor = new Label") < form.indexOf("setReferenceComponents(linked"), + "all siblings must exist before a name-based relationship is installed"); + compile(form, null); + } + + /** + * The designer applies these through ComponentPreviewFactory, so a component the user + * configured looked right on the canvas and came up with every default at runtime. + */ + @Test + void inspectorPropertiesReachTheGeneratedSource() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + set(builder, "document", GuiDocument.parse("/tmp/project/gui/com/example/LoginForm.gui", + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "")); + + String form = invoke(builder, "defaultCompanionSource"); + + assertTrue(form.contains("caption.setEnabled(false);"), form); + assertTrue(form.contains("caption.setVisible(false);")); + assertTrue(form.contains("caption.setRTL(true);")); + assertTrue(form.contains("caption.setGap(6);")); + assertTrue(form.contains("caption.setAlignment(Component.CENTER);")); + assertTrue(form.contains("caption.setTickerEnabled(true);")); + assertTrue(form.contains("blurb.setGap(4);")); + assertFalse(form.contains("blurb.setAlignment"), "SpanLabel has no setAlignment"); + assertTrue(form.contains("email.setColumns(20);")); + assertTrue(form.contains("email.setMaxSize(64);")); + assertTrue(form.contains("email.setEditable(false);")); + assertTrue(form.contains("email.setGrowByContent(false);")); + assertTrue(form.contains("email.setConstraint(TextArea.EMAILADDR);")); + assertTrue(form.contains("email.setAlignment(Component.RIGHT);")); + assertTrue(form.contains("notes.setRows(5);")); + assertTrue(form.contains("remember.setSelected(true);")); + assertTrue(form.contains("choice.setSelected(true);")); + assertTrue(form.contains("submit.setToggle(true);")); + assertTrue(form.contains("volume.setMinValue(10);")); + assertTrue(form.contains("volume.setProgress(40);")); + assertTrue(form.contains("volume.setInfinite(true);")); + assertTrue(form.contains("scroller.setScrollableX(true);")); + assertTrue(form.contains("scroller.setScrollableY(true);")); + assertTrue(form.contains("sections.setTabPlacement(Component.BOTTOM);")); + assertTrue(form.contains("sections.setSelectedIndex(1, false);")); + assertTrue(form.indexOf("sections.addTab(") < form.indexOf("sections.setSelectedIndex"), + "selecting a tab before the tabs exist throws at runtime"); + assertFalse(form.contains("submit.setEnabled"), "untouched properties must stay out of the source"); + + compile(form, null); + } + + /** + * An out of range index is worse than no index: the designer clamps it, the runtime throws. + */ + @Test + void anImpossibleTabSelectionIsNotGenerated() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + set(builder, "document", GuiDocument.parse("/tmp/project/gui/com/example/LoginForm.gui", + "" + + "" + + "" + + "")); + String form = invoke(builder, "defaultCompanionSource"); + assertFalse(form.contains("setSelectedIndex"), form); + compile(form, null); + } + + @Test + void tableCellPercentagesReachTheGeneratedSource() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + set(builder, "document", GuiDocument.parse("/tmp/project/gui/com/example/LoginForm.gui", + "" + + "" + + "" + + "" + + "")); + String form = invoke(builder, "defaultCompanionSource"); + assertTrue(form.contains(".widthPercentage(30)"), form); + assertTrue(form.contains(".widthPercentage(70).heightPercentage(50)"), form); + compile(form, null); + } + + @Test + void aContainerRootGeneratesAContainerAndADialogRootGeneratesADialog() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + set(builder, "document", GuiDocument.parse("/tmp/project/gui/com/example/Panel.gui", + "" + + "")); + String container = invoke(builder, "defaultCompanionSource"); + assertTrue(container.contains("public class Panel extends Container"), container); + assertTrue(container.contains("super(BoxLayout.y());"), container); + compile("Panel", container, null); + + set(builder, "document", GuiDocument.parse("/tmp/project/gui/com/example/Confirm.gui", + "" + + "")); + String dialog = invoke(builder, "defaultCompanionSource"); + assertTrue(dialog.contains("public class Confirm extends Dialog"), dialog); + assertTrue(dialog.contains("super(\"Confirm\", BoxLayout.y());"), dialog); + compile("Confirm", dialog, null); + } + + /** + * Projects scaffolded by cn1:create-gui-form before this editor existed carry the old markers. + * Refusing to touch them meant designing a form, saving, and running an empty screen. + */ + @Test + void aLegacyScaffoldedCompanionIsMigratedAndKeepsUserCode() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + String legacy = "package com.example;\n" + + "public class LoginForm extends com.codename1.ui.Form {\n" + + " public LoginForm() {\n" + + " this(com.codename1.ui.util.Resources.getGlobalResources());\n" + + " }\n" + + " \n" + + " public LoginForm(com.codename1.ui.util.Resources resourceObjectInstance) {\n" + + " initGuiBuilderComponents(resourceObjectInstance);\n" + + " }\n" + + " \n" + + " private void onSubmit(com.codename1.ui.events.ActionEvent event) {\n" + + " System.out.println(\"handled { } \");\n" + + " }\n" + + " \n" + + "//-- DON'T EDIT BELOW THIS LINE!!!\n" + + " private void initGuiBuilderComponents(com.codename1.ui.util.Resources resourceObjectInstance) {\n" + + " }\n" + + "//-- DON'T EDIT ABOVE THIS LINE!!!\n" + + "}\n"; + String generated = invoke(builder, "defaultCompanionSource"); + String merged = merge(builder, legacy, generated); + + assertTrue(merged.contains("email = new TextField"), "the component tree was never generated:\n" + merged); + assertTrue(merged.contains("private void onSubmit(com.codename1.ui.events.ActionEvent event)"), + "the developer's own method was lost:\n" + merged); + assertFalse(merged.contains("initGuiBuilderComponents"), "the legacy generated block survived:\n" + merged); + assertFalse(merged.contains("DON'T EDIT"), merged); + assertFalse(merged.contains("getGlobalResources"), "the legacy constructors survived:\n" + merged); + compile(merged, null); + } + + /** + * Migration regenerates the header, so an import the developer added for their own method has + * to be carried across or the project stops compiling on the first save in this editor. + */ + @Test + void migratingALegacyCompanionKeepsTheImportsItsUserCodeNeeds() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + String legacy = "package com.example;\n" + + "import java.util.ArrayList;\n" + + "import java.util.List;\n" + + "public class LoginForm extends com.codename1.ui.Form {\n" + + " private final List attempts = new ArrayList();\n" + + " public LoginForm() {\n" + + " initGuiBuilderComponents(null);\n" + + " }\n" + + "//-- DON'T EDIT BELOW THIS LINE!!!\n" + + " private void initGuiBuilderComponents(com.codename1.ui.util.Resources r) {\n" + + " }\n" + + "//-- DON'T EDIT ABOVE THIS LINE!!!\n" + + "}\n"; + String generated = invoke(builder, "defaultCompanionSource"); + String merged = merge(builder, legacy, generated); + + assertTrue(merged.contains("import java.util.ArrayList;"), + "the import the carried field needs was dropped:\n" + merged); + assertTrue(merged.contains("import java.util.List;"), merged); + assertTrue(merged.contains("attempts"), "the developer's field was lost:\n" + merged); + compile(merged, null); + } + + @Test + void aHandWrittenCompanionIsLeftAlone() throws Exception { + CodenameOneGUIBuilder builder = builder("none"); + String handWritten = "package com.example;\npublic class LoginForm extends com.codename1.ui.Form {\n}\n"; + assertEquals(handWritten, merge(builder, handWritten, invoke(builder, "defaultCompanionSource"))); + } + + private static String merge(CodenameOneGUIBuilder builder, String existing, String generated) throws Exception { + Method method = CodenameOneGUIBuilder.class.getDeclaredMethod("mergeGeneratedSource", String.class, String.class); + method.setAccessible(true); + return (String) method.invoke(builder, existing, generated); + } + + private static CodenameOneGUIBuilder builder(String strategy) throws Exception { + CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + set(builder, "binding", ProjectBinding.parse( + "projectDir=/tmp/project\nguiDir=/tmp/project/gui\nsourceDir=/tmp/project/java\ncssFile=/tmp/project/theme.css\n")); + set(builder, "document", GuiDocument.parse("/tmp/project/gui/com/example/LoginForm.gui", + "" + + "" + + "" + + "" + + "")); + return builder; + } + + private static void compile(String form, String model) throws Exception { + compile("LoginForm", form, model); + } + + /** + * Compiles into the temporary directory rather than beside the sources: without {@code -d} the + * class files land in the working directory the test suite runs from. + */ + private static void compile(String className, String form, String model) throws Exception { + Path root = Files.createTempDirectory("guibuilder-generated-source"); + Path classes = Files.createDirectories(root.resolve("classes")); + Path pkg = Files.createDirectories(root.resolve("com/example")); + Path formFile = pkg.resolve(className + ".java"); + Files.write(formFile, form.getBytes(StandardCharsets.UTF_8)); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler); + ByteArrayOutputStream errors = new ByteArrayOutputStream(); + int result; + if (model == null) { + result = compiler.run(null, null, errors, "-d", classes.toString(), + "-classpath", System.getProperty("java.class.path"), formFile.toString()); + } else { + Path modelFile = pkg.resolve(className + "Model.java"); + Files.write(modelFile, model.getBytes(StandardCharsets.UTF_8)); + result = compiler.run(null, null, errors, "-d", classes.toString(), + "-classpath", System.getProperty("java.class.path"), + formFile.toString(), modelFile.toString()); + } + assertEquals(0, result, "the generated source did not compile:\n" + + errors.toString("UTF-8") + "\n" + form); + } + + private static void set(Object target, String name, Object value) throws Exception { + Field field = CodenameOneGUIBuilder.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static String invoke(Object target, String name) throws Exception { + Method method = CodenameOneGUIBuilder.class.getDeclaredMethod(name); + method.setAccessible(true); + return (String) method.invoke(target); + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveCssTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveCssTest.java new file mode 100644 index 00000000000..3bf3bfc2146 --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveCssTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.xml.Element; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Editing theme.css has to change what the canvas draws; that is the whole point of live CSS. */ +class LiveCssTest { + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!Display.isInitialized()) Display.init(new JPanel()); + } + + @Test + void editingCssRestylesTheLivePreview() throws Exception { + Path project = Files.createTempDirectory("guibuilder-css"); + Path gui = project.resolve("src/main/guibuilder/com/example"); + Files.createDirectories(gui); + Files.write(gui.resolve("StyledForm.gui"), ("\n" + + "\n" + + " \n" + + "\n").getBytes(StandardCharsets.UTF_8)); + Path css = project.resolve("src/main/css/theme.css"); + Files.createDirectories(css.getParent()); + Files.write(css, "Label { color: #ff0000; }\n".getBytes(StandardCharsets.UTF_8)); + + Path input = Files.createTempFile("guibuilder", ".input"); + Files.write(input, ("projectDir=" + project + "\nguiDir=" + gui.getParent().getParent() + "\n" + + "sourceDir=" + project.resolve("src/main/java") + "\ncssFile=" + css + "\n" + + "initialForm=com.example.StyledForm\n").getBytes(StandardCharsets.UTF_8)); + System.setProperty("guibuilder.input", input.toString()); + System.setProperty("guibuilder.canvasMode", "desktop"); + + CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + builder.init(null); + builder.runApp(); + settle(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.StyledForm"))); + settle(); + + // Open the CSS editor first: that is how anyone edits CSS, and it rebuilds the canvas into + // a split pane, which is the one thing the direct path never exercises. + Display.getInstance().callSeriallyAndWait(() -> { + try { + java.lang.reflect.Method open = CodenameOneGUIBuilder.class.getDeclaredMethod("openCss"); + open.setAccessible(true); + open.invoke(builder); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + settle(); + + int before = foreground(builder, "styled"); + assertEquals(0xff0000, before, "the starting CSS colour must reach the preview"); + + Files.write(css, "Label { color: #00ff00; }\n".getBytes(StandardCharsets.UTF_8)); + assertTrue(onEdt(() -> String.valueOf(builder.reloadProjectCssForTest())).equals("true"), + "recompiling the project CSS failed"); + settle(); + + assertEquals(0x00ff00, foreground(builder, "styled"), + "editing theme.css must restyle the canvas, not just the file"); + } + + private static int foreground(CodenameOneGUIBuilder builder, String name) { + Component preview = find(builder.canvasHostForTest(), builder, name); + assertNotNull(preview, name + " does not render"); + return preview.getUnselectedStyle().getFgColor(); + } + + private static Component find(Container root, CodenameOneGUIBuilder builder, String name) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + Object element = component.getClientProperty("gui.element"); + if (element instanceof Element && name.equals(((Element) element).getAttribute("name"))) return component; + if (component instanceof Container) { + Component nested = find(((Container) component), builder, name); + if (nested != null) return nested; + } + } + return null; + } + + private static String onEdt(java.util.function.Supplier work) { + final String[] out = new String[1]; + Display.getInstance().callSeriallyAndWait(() -> out[0] = work.get()); + return out[0]; + } + + private static void settle() { + for (int i = 0; i < 5; i++) { + try { Thread.sleep(250); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + Display.getInstance().callSeriallyAndWait(() -> { }); + } + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveTypingTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveTypingTest.java new file mode 100644 index 00000000000..f8c5e0a20b4 --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveTypingTest.java @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ui.CodeEditor; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.editor.EditorView; +import com.codename1.xml.Element; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Types real keystrokes into the editor the running application actually builds, through the same + * focus and key routing a keyboard uses. Every previous editor test drove the document API instead, + * which is why editors that were dead to the keyboard kept passing. + */ +class LiveTypingTest { + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!Display.isInitialized()) Display.init(new JPanel()); + } + + @Test + void typingIntoTheJavaEditorReachesTheDocument() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + open(builder, "openSourceEditor", String.class, null); + settle(); + + CodeEditor editor = builder.activeEditorForTest(); + assertNotNull(editor, "the Java editor was never created"); + EditorView view = viewOf(editor); + assertNotNull(view, "the Java editor has no pure editor view; the backend changed"); + assertTrue(view.isFocusable(), "the editing surface must be focusable to receive keys"); + + String before = textOf(editor); + assertTrue(before.contains("// "), "the companion source lost its markers"); + + assertNothingCoversTheEditor(view); + click(view); + assertSame(view, view.getComponentForm().getFocused(), + "clicking the editor did not focus it, so no keystroke can ever reach it"); + + type(view, "PINK"); + String after = textOf(editor); + + assertNotEquals(before, after, "typing changed nothing at all; the Java editor is dead to the keyboard"); + assertTrue(after.contains("PINK"), + "typing must insert the whole word, not drop characters:\n" + userRegion(after)); + } + + @Test + void typingIntoTheCssEditorReachesTheDocument() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + open(builder, "openCss", null, null); + settle(); + + CodeEditor editor = builder.activeEditorForTest(); + assertNotNull(editor, "the CSS editor was never created"); + EditorView view = viewOf(editor); + assertNothingCoversTheEditor(view); + click(view); + assertSame(view, view.getComponentForm().getFocused(), + "clicking the CSS editor did not focus it"); + String before = textOf(editor); + + type(view, "PINK"); + + String after = textOf(editor); + assertNotEquals(before, after, "the CSS editor is dead to the keyboard"); + assertTrue(after.contains("PINK"), "typing must insert the whole word, not drop characters"); + } + + @Test + void typingACssColourRestylesTheCanvas() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + open(builder, "openCss", null, null); + settle(); + + CodeEditor editor = builder.activeEditorForTest(); + assertNotNull(editor, "the CSS editor was never created"); + EditorView view = viewOf(editor); + click(view); + + Component label = previewOf(builder, "nestedDescription"); + assertNotNull(label, "the demo form does not render the label under test"); + int before = label.getUnselectedStyle().getFgColor(); + + // Replace the stylesheet by selecting everything and typing over it, which is what a person + // does. The earlier CSS test called the recompile directly and so never exercised the path + // from a keystroke to a repainted canvas -- the path that was actually broken. + Display.getInstance().callSeriallyAndWait(() -> view.selectAll()); + type(view, "Label { color: #00ff00; }\n"); + awaitForeground(builder, "nestedDescription", 0x00ff00); + + Component after = previewOf(builder, "nestedDescription"); + assertNotNull(after, "the label vanished after the CSS edit"); + assertEquals(0x00ff00, after.getUnselectedStyle().getFgColor(), + "typing a colour into theme.css must restyle the canvas; the editor text was: " + + textOf(editor)); + } + + private static Component previewOf(CodenameOneGUIBuilder builder, String name) { + return findByElementName(builder.canvasHostForTest(), name); + } + + private static Component findByElementName(Container root, String name) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + Object element = component.getClientProperty("gui.element"); + if (element instanceof Element && name.equals(((Element) element).getAttribute("name"))) return component; + if (component instanceof Container) { + Component nested = findByElementName(((Container) component), name); + if (nested != null) return nested; + } + } + return null; + } + + /** The live CSS path debounces twice, so it needs longer than an ordinary EDT flush. */ + /** + * Waits for the live recompile to reach the canvas, rather than sleeping a fixed span and + * hoping. Every keystroke reschedules a 120ms debounce and a 250ms poll drives the recompile, + * so on a loaded machine -- the whole suite running ahead of this test -- a fixed wait expired + * before the colour arrived and the assertion failed for timing rather than behaviour. Giving + * up after the timeout still fails the test, so a colour that never applies is still caught. + * + * @param builder the workspace under test + * @param name the element whose preview is watched + * @param expected the foreground colour the stylesheet should produce + */ + private static void awaitForeground(CodenameOneGUIBuilder builder, String name, int expected) { + for (int attempt = 0; attempt < 100; attempt++) { + Display.getInstance().callSeriallyAndWait(() -> { }); + Component preview = previewOf(builder, name); + if (preview != null && preview.getUnselectedStyle().getFgColor() == expected) return; + try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } + } + } + + + // ---- harness ---------------------------------------------------------------------------- + + /** Drives the real key routing: Form focus, then keyPressed/keyReleased per character. */ + private static void type(EditorView view, String text) { + Form form = view.getComponentForm(); + for (int i = 0; i < text.length(); i++) { + final int code = text.charAt(i); + Display.getInstance().callSeriallyAndWait(() -> { + form.keyPressed(code); + form.keyReleased(code); + }); + } + settle(); + } + + /** + * The designer layers a drag guide over the whole canvas area, and the editor sits inside that + * area. If anything covering the editor claims the pointer, every click lands on the cover and + * the editor can never be focused or typed into -- with no visible sign of why. + */ + private static void assertNothingCoversTheEditor(EditorView view) { + int x = view.getAbsoluteX() + view.getWidth() / 2; + int y = view.getAbsoluteY() + Math.min(view.getHeight() - 2, 20); + Form form = view.getComponentForm(); + final Component[] hit = new Component[1]; + Display.getInstance().callSeriallyAndWait(() -> hit[0] = form.getComponentAt(x, y)); + assertNotNull(hit[0], "nothing at all is hit testable where the editor is drawn"); + assertFalse(hit[0] instanceof com.codename1.guibuilder.ui.DragGuideOverlay, + "the drag guide overlay claims the pointer over the editor, so clicks never reach it"); + assertTrue(hit[0] == view || isDescendantOf(hit[0], view) || isDescendantOf(view, hit[0]), + "a pointer over the editor is claimed by " + hit[0].getClass().getName() + + " instead of the editing surface"); + } + + private static boolean isDescendantOf(Component candidate, Component ancestor) { + for (Container parent = candidate.getParent(); parent != null; parent = parent.getParent()) { + if (parent == ancestor) return true; + } + return false; + } + + /** + * Clicks into the editor exactly as a user does, instead of assigning focus by hand. Forcing + * focus is what made the earlier tests pass while the editor was unusable in the application. + */ + private static void click(EditorView view) { + int x = view.getAbsoluteX() + view.getWidth() / 2; + int y = view.getAbsoluteY() + Math.min(view.getHeight() - 2, 20); + Form form = view.getComponentForm(); + Display.getInstance().callSeriallyAndWait(() -> { + form.pointerPressed(x, y); + form.pointerReleased(x, y); + }); + settle(); + } + + private static String textOf(CodeEditor editor) { + final String[] out = new String[1]; + Display.getInstance().callSeriallyAndWait(() -> editor.getText(value -> out[0] = value)); + settle(); + return out[0] == null ? "" : out[0]; + } + + private static EditorView viewOf(CodeEditor editor) { + return findView(editor); + } + + private static EditorView findView(Container root) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component instanceof EditorView) return ((EditorView) component); + if (component instanceof Container) { + EditorView nested = findView(((Container) component)); + if (nested != null) return nested; + } + } + return null; + } + + private static String userRegion(String source) { + int start = source.indexOf("// "); + int end = source.indexOf("// "); + return start < 0 || end < 0 ? source : source.substring(start, end + 27); + } + + private static void open(CodenameOneGUIBuilder builder, String method, Class argType, Object arg) { + Display.getInstance().callSeriallyAndWait(() -> { + try { + java.lang.reflect.Method m = argType == null + ? CodenameOneGUIBuilder.class.getDeclaredMethod(method) + : CodenameOneGUIBuilder.class.getDeclaredMethod(method, argType); + m.setAccessible(true); + if (argType == null) m.invoke(builder); else m.invoke(builder, arg); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + } + + private static CodenameOneGUIBuilder workspace() throws Exception { + Path demo = new java.io.File("../demo-project").getCanonicalFile().toPath(); + Path input = Files.createTempFile("guibuilder", ".input"); + Files.write(input, ("projectDir=" + demo + "\nguiDir=" + demo.resolve("src/main/guibuilder") + + "\nsourceDir=" + demo.resolve("src/main/java") + + "\ncssFile=" + demo.resolve("src/main/css/theme.css") + + "\ninitialForm=com.example.NestedLayoutsForm\n").getBytes(StandardCharsets.UTF_8)); + input.toFile().deleteOnExit(); + System.setProperty("guibuilder.input", input.toString()); + System.setProperty("guibuilder.canvasMode", "desktop"); + CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + builder.init(null); + builder.runApp(); + settle(); + return builder; + } + + private static void settle() { + for (int i = 0; i < 4; i++) { + try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + Display.getInstance().callSeriallyAndWait(() -> { }); + } + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveWorkspaceDragTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveWorkspaceDragTest.java new file mode 100644 index 00000000000..51aa953978f --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveWorkspaceDragTest.java @@ -0,0 +1,355 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.xml.Element; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Drives the assembled workspace -- toolbar, canvas, inspector, hierarchy -- rather than the + * document model alone. The model-level tests pass for gestures that visibly fail in the running + * editor, because the defects live in what the canvas holds after a commit, not in the XML. + */ +class LiveWorkspaceDragTest { + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!Display.isInitialized()) Display.init(new JPanel()); + } + + @Test + void aCrossContainerDragLeavesExactlyOnePreviewPerComponent() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + flushEdt(); + assertOnePreviewPerComponent(builder, "after opening the form"); + + assertNull(onEdt(() -> builder.mcpDragComponent("nestedB", "nestedAction", "below", null, null))); + flushEdt(); + + assertOnePreviewPerComponent(builder, "after dragging nestedB into the other column"); + assertPreviewsSitInsideTheirModelParent(builder, "after dragging nestedB into the other column"); + } + + @Test + void repeatedCrossContainerDragsStayStable() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + flushEdt(); + + String[] moved = {"nestedB", "nestedC", "nestedD", "nestedA"}; + for (String name : moved) { + assertNull(onEdt(() -> builder.mcpDragComponent(name, "nestedAction", "below", null, null)), + "dragging " + name + " reported an error"); + flushEdt(); + assertOnePreviewPerComponent(builder, "after dragging " + name); + assertPreviewsSitInsideTheirModelParent(builder, "after dragging " + name); + } + } + + @Test + void drainingAContainerCompletelyKeepsEveryComponent() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + flushEdt(); + List everything = componentNames(builder); + assertEquals(11, everything.size(), everything.toString()); + + for (String name : new String[]{"nestedA", "nestedB", "nestedC", "nestedD"}) { + assertNull(onEdt(() -> builder.mcpDragComponent(name, "nestedAction", "below", null, null)), + "dragging " + name + " reported an error"); + flushEdt(); + assertEquals(everything, componentNames(builder), + name + " left the document instead of moving; it is in neither container"); + assertEquals("rightActions", parentName(builder, name), hierarchy(builder).toString()); + assertOnePreviewPerComponent(builder, "after moving " + name); + assertPreviewsSitInsideTheirModelParent(builder, "after moving " + name); + assertEveryComponentIsActuallyVisible(builder, "after moving " + name); + } + assertEquals(0, childCount(builder, "leftGrid"), hierarchy(builder).toString()); + assertEquals(6, childCount(builder, "rightActions"), hierarchy(builder).toString()); + } + + @Test + void anEmptiedContainerCanBeFilledAgainByDragging() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + flushEdt(); + List everything = componentNames(builder); + + for (String name : new String[]{"nestedA", "nestedB", "nestedC", "nestedD"}) { + assertNull(onEdt(() -> builder.mcpDragComponent(name, "nestedAction", "below", null, null))); + flushEdt(); + } + assertEquals(0, childCount(builder, "leftGrid")); + + // An emptied container renders only its "drop components here" hint. Dropping onto that + // hint has to resolve to the container, or the components have nowhere to go back to. + for (String name : new String[]{"nestedA", "nestedB"}) { + assertNull(onEdt(() -> builder.mcpDragComponent(name, "leftGrid", "center", null, null)), + "the emptied container refused " + name); + flushEdt(); + assertEquals(everything, componentNames(builder), name + " was lost on the way back"); + assertEquals("leftGrid", parentName(builder, name), hierarchy(builder).toString()); + assertOnePreviewPerComponent(builder, "after returning " + name); + } + } + + @Test + void movingAPopulatedContainerThroughTheCanvasKeepsItsChildren() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + flushEdt(); + List everything = componentNames(builder); + + assertNull(onEdt(() -> builder.mcpDragComponent("leftGrid", "nestedAction", "below", null, null)), + "dragging a populated container reported an error"); + flushEdt(); + + assertEquals(everything, componentNames(builder), "moving a container lost part of its subtree"); + assertEquals("rightActions", parentName(builder, "leftGrid"), hierarchy(builder).toString()); + assertEquals(4, childCount(builder, "leftGrid"), hierarchy(builder).toString()); + assertOnePreviewPerComponent(builder, "after moving a populated container"); + assertPreviewsSitInsideTheirModelParent(builder, "after moving a populated container"); + } + + @Test + void undoAfterACrossContainerDragRestoresBothTheModelAndTheCanvas() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + flushEdt(); + List before = hierarchy(builder); + + assertNull(onEdt(() -> builder.mcpDragComponent("nestedB", "nestedAction", "below", null, null))); + flushEdt(); + assertNotEquals(before, hierarchy(builder), "the drag must actually change the tree"); + + assertNull(onEdt(() -> builder.mcpCommand("undo"))); + flushEdt(); + + assertEquals(before, hierarchy(builder), "undo must restore the tree exactly"); + assertOnePreviewPerComponent(builder, "after undo"); + assertPreviewsSitInsideTheirModelParent(builder, "after undo"); + for (Element element : builder.selectedElementsSnapshot()) { + assertTrue(builder.isActiveDocumentElement(element), + "the selection still points at the pre-undo tree"); + } + } + + @Test + void aDragImmediatelyAfterUndoStillCommits() throws Exception { + CodenameOneGUIBuilder builder = workspace(); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + flushEdt(); + assertNull(onEdt(() -> builder.mcpDragComponent("nestedB", "nestedAction", "below", null, null))); + flushEdt(); + assertNull(onEdt(() -> builder.mcpCommand("undo"))); + flushEdt(); + + assertNull(onEdt(() -> builder.mcpDragComponent("nestedC", "nestedAction", "below", null, null)), + "the first drag after an undo must still commit"); + flushEdt(); + assertEquals("rightActions", parentName(builder, "nestedC"), hierarchy(builder).toString()); + assertOnePreviewPerComponent(builder, "after the post-undo drag"); + } + + // ---- harness --------------------------------------------------------------------------- + + /** Runs work on the EDT, exactly as GuiBuilderMcpController does for every MCP tool. */ + private static String onEdt(java.util.function.Supplier work) { + final String[] out = new String[1]; + Display.getInstance().callSeriallyAndWait(() -> out[0] = work.get()); + return out[0]; + } + + private static CodenameOneGUIBuilder workspace() throws Exception { + System.setProperty("guibuilder.input", demoBinding().toString()); + System.setProperty("guibuilder.canvasMode", "desktop"); + CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + builder.init(null); + builder.runApp(); + flushEdt(); + return builder; + } + + /** The binding holds absolute paths, so it is written per run rather than checked in. */ + private static Path demoBinding() throws Exception { + Path demo = new File("../demo-project").getCanonicalFile().toPath(); + Path input = Files.createTempFile("guibuilder", ".input"); + Files.write(input, ("projectDir=" + demo + "\n" + + "guiDir=" + demo.resolve("src/main/guibuilder") + "\n" + + "sourceDir=" + demo.resolve("src/main/java") + "\n" + + "cssFile=" + demo.resolve("src/main/css/theme.css") + "\n" + + "initialForm=com.example.NestedLayoutsForm\n").getBytes(StandardCharsets.UTF_8)); + input.toFile().deleteOnExit(); + return input; + } + + /** Runs queued EDT work to completion, including refreshes that queue further refreshes. */ + private static void flushEdt() { + for (int i = 0; i < 6; i++) { + if (Display.getInstance().isEdt()) return; + try { Thread.sleep(250); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + Display.getInstance().callSeriallyAndWait(() -> { }); + } + } + + private static void assertOnePreviewPerComponent(CodenameOneGUIBuilder builder, String when) { + Container canvas = builder.canvasHostForTest(); + assertNotNull(canvas, "the workspace never built a canvas"); + for (Element element : builder.documentForTest().components()) { + int count = countPreviews(canvas, element, 0); + assertEquals(1, count, name(element) + " has " + count + " previews on the canvas " + when + + "; a stale preview shadows the live one for hit testing and geometry"); + } + } + + private static void assertPreviewsSitInsideTheirModelParent(CodenameOneGUIBuilder builder, String when) { + Container canvas = builder.canvasHostForTest(); + for (Element element : builder.documentForTest().components()) { + Element parent = builder.documentForTest().parentOf(element); + if (parent == null) continue; + Component preview = findPreview(canvas, element); + Component parentPreview = findPreview(canvas, parent); + if (preview == null || parentPreview == null) continue; + assertSame(parentPreview, enclosingPreview(preview), + name(element) + " renders inside " + name(elementOf(enclosingPreview(preview))) + + " but the model says " + name(parent) + " " + when); + } + } + + private static Component enclosingPreview(Component component) { + for (Container parent = component.getParent(); parent != null; parent = parent.getParent()) { + if (parent.getClientProperty("gui.element") instanceof Element) return parent; + } + return null; + } + + private static Element elementOf(Component component) { + Object element = component == null ? null : component.getClientProperty("gui.element"); + return element instanceof Element ? ((Element) element) : null; + } + + private static int countPreviews(Container root, Element element, int found) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component.getClientProperty("gui.element") == element) found++; + if (component instanceof Container) found = countPreviews(((Container) component), element, found); + } + return found; + } + + private static Component findPreview(Container root, Element element) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component.getClientProperty("gui.element") == element) return component; + if (component instanceof Container) { + Component nested = findPreview(((Container) component), element); + if (nested != null) return nested; + } + } + return null; + } + + /** + * A component that renders at zero size, or entirely outside the form it belongs to, has + * effectively vanished from the designer even though the model still lists it -- which is what + * "they are gone from both containers" looks like from the canvas. + */ + private static void assertEveryComponentIsActuallyVisible(CodenameOneGUIBuilder builder, String when) { + Container canvas = builder.canvasHostForTest(); + Component form = findPreview(canvas, builder.documentForTest().root()); + assertNotNull(form, "the form itself does not render " + when); + for (Element element : builder.documentForTest().components()) { + if (element == builder.documentForTest().root()) continue; + Component preview = findPreview(canvas, element); + assertNotNull(preview, name(element) + " has no preview " + when); + assertTrue(preview.getWidth() > 0 && preview.getHeight() > 0, + name(element) + " renders at " + preview.getWidth() + "x" + preview.getHeight() + + " " + when + "; it is invisible on the canvas"); + boolean insideHorizontally = preview.getAbsoluteX() + preview.getWidth() > form.getAbsoluteX() + && preview.getAbsoluteX() < form.getAbsoluteX() + form.getWidth(); + boolean insideVertically = preview.getAbsoluteY() + preview.getHeight() > form.getAbsoluteY() + && preview.getAbsoluteY() < form.getAbsoluteY() + form.getHeight(); + assertTrue(insideHorizontally && insideVertically, + name(element) + " renders outside the form " + when + "; component at " + + preview.getAbsoluteX() + "," + preview.getAbsoluteY() + + " " + preview.getWidth() + "x" + preview.getHeight() + + ", form at " + form.getAbsoluteX() + "," + form.getAbsoluteY() + + " " + form.getWidth() + "x" + form.getHeight()); + } + } + + private static List componentNames(CodenameOneGUIBuilder builder) { + List names = new ArrayList<>(); + for (Element element : builder.documentForTest().components()) names.add(name(element)); + java.util.Collections.sort(names); + return names; + } + + private static int childCount(CodenameOneGUIBuilder builder, String containerName) { + for (Element element : builder.documentForTest().components()) { + if (containerName.equals(name(element))) { + return com.codename1.guibuilder.model.GuiDocument.componentsIn(element).size(); + } + } + return -1; + } + + private static List hierarchy(CodenameOneGUIBuilder builder) { + List rows = new ArrayList<>(); + for (Element element : builder.documentForTest().components()) { + rows.add(parentName(builder, name(element)) + "/" + name(element)); + } + return rows; + } + + private static String parentName(CodenameOneGUIBuilder builder, String componentName) { + for (Element element : builder.documentForTest().components()) { + if (componentName.equals(name(element))) { + Element parent = builder.documentForTest().parentOf(element); + return parent == null ? "-" : name(parent); + } + } + return null; + } + + private static String name(Element element) { + if (element == null) return "?"; + String value = element.getAttribute("name"); + return value == null ? element.getAttribute("type") : value; + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/NestedHierarchyTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/NestedHierarchyTest.java new file mode 100644 index 00000000000..115ea2d805e --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/NestedHierarchyTest.java @@ -0,0 +1,601 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.guibuilder.model.GuiDocument; +import com.codename1.guibuilder.ui.ComponentPreviewFactory; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.xml.Element; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Nested containers are where the designer is weakest: a move is a remove from one parent and an + * add to another, and anything that fails between the two loses the component from the document + * entirely. These tests hold the whole tree to account after every single gesture rather than + * checking only the component that moved, because the symptom of the interesting bugs is a + * component that is in neither its old parent nor its new one. + */ +class NestedHierarchyTest { + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!com.codename1.ui.Display.isInitialized()) com.codename1.ui.Display.init(new JPanel()); + } + + // ---- draining a container completely -------------------------------------------------- + + @Test + void movingEveryChildOutOfAContainerLosesNone() throws Exception { + GuiDocument document = threeLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + + for (String child : Arrays.asList("nestedA", "nestedB", "nestedC", "nestedD")) { + Element moved = named(document, child); + Element destination = named(document, "rightActions"); + assertTrue(builder.applyDropPlan(moved, plan(document, destination, destination, "BoxLayout", true), 0, 0), + "moving " + child + " out of the grid was rejected"); + assertHierarchyIsSound(document, everything, "after moving " + child); + assertSame(destination, document.parentOf(moved), + child + " did not land in the destination: " + document.toXml()); + } + assertEquals(0, GuiDocument.componentsIn(named(document, "leftGrid")).size(), + "the source container should now be empty: " + document.toXml()); + assertEquals(6, GuiDocument.componentsIn(named(document, "rightActions")).size(), + "every moved component must be in the destination: " + document.toXml()); + } + + @Test + void anEmptiedContainerStillAcceptsComponentsBack() throws Exception { + GuiDocument document = threeLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element grid = named(document, "leftGrid"); + Element rightActions = named(document, "rightActions"); + + for (String child : Arrays.asList("nestedA", "nestedB", "nestedC", "nestedD")) { + assertTrue(builder.applyDropPlan(named(document, child), + plan(document, rightActions, rightActions, "BoxLayout", true), 0, 0)); + } + assertEquals(0, GuiDocument.componentsIn(grid).size()); + + // Dropping onto a container that renders only its empty-state hint must still target the + // container, not fall through to whatever is behind it. + for (String child : Arrays.asList("nestedA", "nestedB")) { + assertTrue(builder.applyDropPlan(named(document, child), + plan(document, grid, grid, "GridLayout", true), 0, 0), + "the emptied container refused " + child); + assertHierarchyIsSound(document, everything, "after returning " + child); + } + assertEquals(Arrays.asList("nestedA", "nestedB"), childNames(grid), document.toXml()); + } + + @Test + void drainingAContainerThenItsParentKeepsEveryComponent() throws Exception { + GuiDocument document = threeLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element root = document.root(); + + for (String child : Arrays.asList("nestedA", "nestedB", "nestedC", "nestedD")) { + assertTrue(builder.applyDropPlan(named(document, child), + plan(document, named(document, "rightActions"), named(document, "rightActions"), "BoxLayout", true), 0, 0)); + assertHierarchyIsSound(document, everything, "after draining " + child); + } + // Now move the emptied container itself up to the form. + assertTrue(builder.applyDropPlan(named(document, "leftGrid"), + plan(document, root, root, "BorderLayout", true), 0, 0)); + assertHierarchyIsSound(document, everything, "after moving the emptied container to the form"); + assertSame(root, document.parentOf(named(document, "leftGrid")), document.toXml()); + } + + // ---- moving containers, not just leaves ------------------------------------------------ + + @Test + void movingAContainerCarriesItsWholeSubtree() throws Exception { + GuiDocument document = threeLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element grid = named(document, "leftGrid"); + Element rightActions = named(document, "rightActions"); + + assertTrue(builder.applyDropPlan(grid, plan(document, rightActions, rightActions, "BoxLayout", true), 0, 0)); + + assertHierarchyIsSound(document, everything, "after moving a populated container"); + assertSame(rightActions, document.parentOf(grid), document.toXml()); + assertEquals(Arrays.asList("nestedA", "nestedB", "nestedC", "nestedD"), childNames(grid), + "the subtree must travel intact: " + document.toXml()); + } + + @Test + void aContainerCannotBeDroppedIntoItsOwnDescendant() throws Exception { + GuiDocument document = threeLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element columns = named(document, "contentColumns"); + Element grid = named(document, "leftGrid"); + String before = document.toXml(); + + builder.applyDropPlan(columns, plan(document, grid, grid, "GridLayout", true), 0, 0); + + assertEquals(before, document.toXml(), "a cycle must be refused without touching the document"); + assertHierarchyIsSound(document, everything, "after a refused cyclic drop"); + } + + @Test + void deeplyNestedComponentsSurviveAMoveToTheOutermostForm() throws Exception { + GuiDocument document = fourLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element root = document.root(); + + assertTrue(builder.applyDropPlan(named(document, "deepLeaf"), + plan(document, root, root, "BorderLayout", true), 0, 0)); + + assertHierarchyIsSound(document, everything, "after hoisting a leaf from four levels down"); + assertSame(root, document.parentOf(named(document, "deepLeaf")), document.toXml()); + } + + @Test + void aComponentCanTravelDownIntoTheDeepestContainer() throws Exception { + GuiDocument document = fourLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element deepest = named(document, "levelThree"); + + assertTrue(builder.applyDropPlan(named(document, "topLevelButton"), + plan(document, deepest, deepest, "BoxLayout", true), 0, 0)); + + assertHierarchyIsSound(document, everything, "after pushing a component four levels down"); + assertSame(deepest, document.parentOf(named(document, "topLevelButton")), document.toXml()); + } + + // ---- every parent layout ---------------------------------------------------------------- + + @Test + void everyParentLayoutAcceptsAComponentFromAnotherContainer() throws Exception { + for (String layout : Arrays.asList("BoxLayout", "BorderLayout", "GridLayout", "FlowLayout", "TableLayout", "LayeredLayout")) { + GuiDocument document = documentWithDestinationLayout(layout); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element destination = named(document, "destination"); + Element traveller = named(document, "traveller"); + + assertTrue(builder.applyDropPlan(traveller, plan(document, destination, destination, layout, true), 0, 0), + layout + " refused an incoming component"); + assertHierarchyIsSound(document, everything, "after a drop into " + layout); + assertSame(destination, document.parentOf(traveller), + layout + " did not adopt the component: " + document.toXml()); + assertRendersFaithfully(document, "after a drop into " + layout); + } + } + + @Test + void everySourceLayoutReleasesItsLastChild() throws Exception { + for (String layout : Arrays.asList("BoxLayout", "BorderLayout", "GridLayout", "FlowLayout", "TableLayout", "LayeredLayout")) { + GuiDocument document = documentWithSourceLayout(layout); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element destination = named(document, "destination"); + + assertTrue(builder.applyDropPlan(named(document, "traveller"), + plan(document, destination, destination, "BoxLayout", true), 0, 0), + layout + " would not release its only child"); + assertHierarchyIsSound(document, everything, "after emptying a " + layout); + assertEquals(0, GuiDocument.componentsIn(named(document, "source")).size(), + layout + " kept a copy of the component it released: " + document.toXml()); + } + } + + // ---- filling a container past what its layout can show -------------------------------------- + + @Test + void componentsPiledIntoABorderLayoutNeverShareARegion() throws Exception { + GuiDocument document = document("\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + Element destination = named(document, "destination"); + + for (String child : Arrays.asList("one", "two", "three")) { + CodenameOneGUIBuilder.DropPlan plan = plan(document, destination, destination, "BorderLayout", true); + plan.constraint = "Center"; + plan.occupied = GuiDocument.childAtBorderConstraint(destination, "Center", named(document, child)); + builder.applyDropPlan(named(document, child), plan, 0, 0); + assertHierarchyIsSound(document, everything, "after piling " + child + " into Center"); + } + assertNoSharedBorderRegion(destination, document); + assertRendersFaithfully(document, "after piling components into one BorderLayout region"); + } + + @Test + void aGridFilledPastItsDeclaredCellsStillShowsEveryChild() throws Exception { + GuiDocument document = documentWithDestinationLayout("GridLayout"); + CodenameOneGUIBuilder builder = builder(document); + Element destination = named(document, "destination"); + Set everything = names(document); + + for (int i = 0; i < 6; i++) { + document.select(destination); + Element added = document.addComponent("Button"); + assertNotNull(added, "the grid refused an extra child"); + everything.add(added.getAttribute("name")); + } + assertHierarchyIsSound(document, everything, "after overfilling a 2x2 grid"); + assertRendersFaithfully(document, "after overfilling a 2x2 grid"); + } + + @Test + void aTableFilledPastItsDeclaredCellsStillShowsEveryChild() throws Exception { + GuiDocument document = documentWithDestinationLayout("TableLayout"); + CodenameOneGUIBuilder builder = builder(document); + Element destination = named(document, "destination"); + Set everything = names(document); + + for (int i = 0; i < 6; i++) { + document.select(destination); + Element added = document.addComponent("Button"); + assertNotNull(added, "the table refused an extra child"); + everything.add(added.getAttribute("name")); + } + assertHierarchyIsSound(document, everything, "after overfilling a 2x2 table"); + assertNoSharedTableCell(destination, document); + assertRendersFaithfully(document, "after overfilling a 2x2 table"); + } + + private static void assertNoSharedBorderRegion(Element parent, GuiDocument document) { + Set used = new LinkedHashSet<>(); + for (Element child : GuiDocument.componentsIn(parent)) { + String constraint = GuiDocument.effectiveBorderConstraint(parent, child); + assertTrue(used.add(constraint), + "two components share the " + constraint + " region, so only one of them is ever" + + " visible:\n" + document.toXml()); + } + } + + private static void assertNoSharedTableCell(Element parent, GuiDocument document) { + Set used = new LinkedHashSet<>(); + for (Element child : GuiDocument.componentsIn(parent)) { + String cell = GuiDocument.effectiveTableRow(parent, child) + ":" + + GuiDocument.effectiveTableColumn(parent, child); + assertTrue(used.add(cell), + "two components share cell " + cell + ", so only one of them is ever visible:\n" + + document.toXml()); + } + } + + @Test + void emptyingAColumnDoesNotPushTheOtherColumnOffTheDevice() { + // Exactly the shape of NestedLayoutsForm once its grid column has been drained. + GuiDocument document = document("\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""); + + // Phone portrait is the narrowest canvas mode the editor offers, and the one the empty + // container hint used to overflow: it asked for its full text width, which left no room + // for the neighbouring column and pushed every component past the right edge. + Container rendered = (Container) render(document, 720, 1200); + Component form = findPreview(rendered, document.root()); + int rightEdge = (form == null ? rendered : form).getAbsoluteX() + + (form == null ? rendered : form).getWidth(); + for (Element element : document.components()) { + if (element == document.root()) continue; + Component preview = findPreview(rendered, element); + assertNotNull(preview, element.getAttribute("name") + " does not render"); + assertTrue(preview.getAbsoluteX() < rightEdge, + element.getAttribute("name") + " starts at x=" + preview.getAbsoluteX() + + ", past the device's right edge at " + rightEdge + + "; emptying a column must not push its neighbour off the canvas"); + } + } + + // ---- undo across nesting ------------------------------------------------------------------ + + @Test + void undoRestoresEveryStepOfADrainInReverse() throws Exception { + GuiDocument document = threeLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + List snapshots = new ArrayList<>(); + + for (String child : Arrays.asList("nestedA", "nestedB", "nestedC", "nestedD")) { + snapshots.add(document.toXml()); + assertTrue(builder.applyDropPlan(named(document, child), + plan(document, named(document, "rightActions"), named(document, "rightActions"), "BoxLayout", true), 0, 0)); + } + for (int i = snapshots.size() - 1; i >= 0; i--) { + assertTrue(document.undo(), "undo ran out of history at step " + i); + assertEquals(snapshots.get(i), document.toXml(), "undo did not restore step " + i); + assertHierarchyIsSound(document, everything, "after undoing step " + i); + } + } + + @Test + void redoReplaysADrainExactly() throws Exception { + GuiDocument document = threeLevelDocument(); + CodenameOneGUIBuilder builder = builder(document); + Set everything = names(document); + + for (String child : Arrays.asList("nestedA", "nestedB")) { + assertTrue(builder.applyDropPlan(named(document, child), + plan(document, named(document, "rightActions"), named(document, "rightActions"), "BoxLayout", true), 0, 0)); + } + String drained = document.toXml(); + assertTrue(document.undo()); + assertTrue(document.undo()); + assertTrue(document.redo()); + assertTrue(document.redo()); + + assertEquals(drained, document.toXml(), "redo must reproduce the drained tree exactly"); + assertHierarchyIsSound(document, everything, "after redoing a drain"); + } + + // ---- invariants --------------------------------------------------------------------------- + + /** + * A move is only correct if the whole document is still coherent afterwards. Checks that no + * component was lost or duplicated, that parent links agree with the child lists, and that the + * tree survives a serialize/parse round trip -- the form is written to disk in exactly that way. + */ + private static void assertHierarchyIsSound(GuiDocument document, Set expected, String when) { + List all = document.components(); + List found = new ArrayList<>(); + for (Element element : all) found.add(element.getAttribute("name")); + + Set unique = new LinkedHashSet<>(found); + assertEquals(found.size(), unique.size(), + "duplicate component names " + when + ": " + found + "\n" + document.toXml()); + assertEquals(expected, unique, + "the set of components changed " + when + "; something was lost or invented\n" + document.toXml()); + + for (Element element : all) { + if (element == document.root()) continue; + Element parent = document.parentOf(element); + assertNotNull(parent, element.getAttribute("name") + " has no parent " + when + + "; it is detached from the tree\n" + document.toXml()); + assertTrue(GuiDocument.componentsIn(parent).contains(element), + element.getAttribute("name") + " is not listed by the parent that claims it " + when + + "\n" + document.toXml()); + } + + GuiDocument reparsed = GuiDocument.parse("Form.gui", document.toXml()); + assertEquals(structure(document), structure(reparsed), + "the tree does not survive a save/load round trip " + when + "\n" + document.toXml()); + } + + /** + * Every component must render exactly once and occupy real space. A component the model still + * lists but that draws at zero size has disappeared as far as anyone using the editor is + * concerned -- two components sharing one BorderLayout region behave exactly like that. + */ + private static void assertRendersFaithfully(GuiDocument document, String when) { + Container rendered = (Container) render(document, 600, 800); + for (Element element : document.components()) { + if (element == document.root()) continue; + assertEquals(1, countPreviews(rendered, element, 0), + element.getAttribute("name") + " does not have exactly one preview " + when + + "\n" + document.toXml()); + Component preview = findPreview(rendered, element); + assertNotNull(preview, element.getAttribute("name") + " has no preview " + when); + assertTrue(preview.getWidth() > 0 && preview.getHeight() > 0, + element.getAttribute("name") + " renders at " + preview.getWidth() + "x" + + preview.getHeight() + " " + when + "; it is invisible in the designer\n" + + document.toXml()); + } + } + + private static Component findPreview(Container root, Element element) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component.getClientProperty("gui.element") == element) return component; + if (component instanceof Container) { + Component nested = findPreview(((Container) component), element); + if (nested != null) return nested; + } + } + return null; + } + + private static List structure(GuiDocument document) { + List rows = new ArrayList<>(); + for (Element element : document.components()) { + Element parent = document.parentOf(element); + rows.add((parent == null ? "-" : parent.getAttribute("name")) + "/" + element.getAttribute("name")); + } + return rows; + } + + // ---- fixtures ----------------------------------------------------------------------------- + + private static GuiDocument threeLevelDocument() { + return document("\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""); + } + + private static GuiDocument fourLevelDocument() { + return document("\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""); + } + + private static GuiDocument documentWithDestinationLayout(String layout) { + return document("\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""); + } + + private static GuiDocument documentWithSourceLayout(String layout) { + return document("\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""); + } + + // ---- helpers ------------------------------------------------------------------------------ + + private static CodenameOneGUIBuilder.DropPlan plan(GuiDocument document, Element parent, Element target, + String layout, boolean after) { + CodenameOneGUIBuilder.DropPlan plan = new CodenameOneGUIBuilder.DropPlan(); + plan.document = document; + plan.parent = parent; + plan.target = target; + plan.layout = layout; + plan.after = after; + plan.valid = true; + if ("TableLayout".equals(layout)) plan.tableCell = new int[]{0, 0}; + return plan; + } + + private static Set names(GuiDocument document) { + Set out = new LinkedHashSet<>(); + for (Element element : document.components()) out.add(element.getAttribute("name")); + return out; + } + + private static List childNames(Element parent) { + List out = new ArrayList<>(); + for (Element child : GuiDocument.componentsIn(parent)) out.add(child.getAttribute("name")); + return out; + } + + private static Element named(GuiDocument document, String name) { + for (Element element : document.components()) { + if (name.equals(element.getAttribute("name"))) return element; + } + return null; + } + + private static int countPreviews(Container root, Element element, int found) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + if (component.getClientProperty("gui.element") == element) found++; + if (component instanceof Container) found = countPreviews(((Container) component), element, found); + } + return found; + } + + private static Component render(GuiDocument document, int width, int height) { + Component rendered = ComponentPreviewFactory.create(document.root(), null, + new ComponentPreviewFactory.SelectionHandler() { + public void selected(Element element) { } + public void dragPressed(Element element, Component source, int x, int y) { } + public boolean isDragActive() { return false; } + public void editContent(Element element) { } + }); + rendered.setWidth(width); + rendered.setHeight(height); + layoutNested((Container) rendered); + return rendered; + } + + private static void layoutNested(Container container) { + container.layoutContainer(); + for (int i = 0; i < container.getComponentCount(); i++) { + Component child = container.getComponentAt(i); + if (child instanceof Container) layoutNested((Container) child); + } + } + + private static GuiDocument document(String xml) { + return GuiDocument.parse("Form.gui", xml); + } + + private static CodenameOneGUIBuilder builder(GuiDocument document) throws Exception { + CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + Field field = CodenameOneGUIBuilder.class.getDeclaredField("document"); + field.setAccessible(true); + field.set(builder, document); + Container canvas = new Container(new LayeredLayout()); + canvas.setWidth(600); + canvas.setHeight(800); + canvas.add(render(document, 600, 800)); + layoutNested(canvas); + Field canvasField = CodenameOneGUIBuilder.class.getDeclaredField("canvasHost"); + canvasField.setAccessible(true); + canvasField.set(builder, canvas); + return builder; + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssCompilesTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssCompilesTest.java new file mode 100644 index 00000000000..8af0129c148 --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssCompilesTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ui.css.CSSThemeCompiler; +import com.codename1.ui.util.MutableResource; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Hashtable; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * If the project's theme.css fails to compile the editor silently falls back to its own theme, so + * the canvas shows styling that has nothing to do with the CSS being edited. + */ +class ProjectCssCompilesTest { + @BeforeAll static void init() { + if (!com.codename1.ui.Display.isInitialized()) com.codename1.ui.Display.init(new JPanel()); + } + + @Test + void theDemoProjectThemeCompilesIntoAUsableTheme() throws Exception { + File css = new File("../demo-project/src/main/css/theme.css"); + assertTrue(css.isFile(), "demo theme.css is missing at " + css.getAbsolutePath()); + MutableResource resources = new MutableResource(); + try { + new CSSThemeCompiler().compile( + new String(Files.readAllBytes(css.toPath()), StandardCharsets.UTF_8), + resources, "ProjectTheme"); + } catch (RuntimeException ex) { + fail("the demo theme.css does not compile, so the canvas can never show it: " + ex); + } + Hashtable theme = resources.getTheme("ProjectTheme"); + assertNotNull(theme, "compilation produced no theme"); + assertFalse(theme.isEmpty(), "the compiled theme is empty"); + System.out.println("PROJECT CSS keys: " + theme.size()); + int titleKeys = 0; + for (Object key : theme.keySet()) { + if (String.valueOf(key).startsWith("Title")) titleKeys++; + } + System.out.println("PROJECT CSS Title keys: " + titleKeys); + assertTrue(titleKeys > 0, "the compiled theme has no Title styling even though theme.css sets it"); + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssStylesPreviewTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssStylesPreviewTest.java new file mode 100644 index 00000000000..aea7541b668 --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssStylesPreviewTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.xml.Element; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.stream.Stream; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The design canvas has to be styled by the project's own stylesheet. Everything else about live CSS + * is pointless if what the canvas shows is unrelated to the file being edited, and a test that + * writes its own one-rule stylesheet cannot tell the difference -- it proves a rule can reach the + * preview, not that the project's rules do. + */ +class ProjectCssStylesPreviewTest { + private static final int PREVIEW_WIDTH = 900; + + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!Display.isInitialized()) Display.init(new JPanel()); + } + + @Test + void theCanvasIsStyledByTheProjectStylesheet() throws Exception { + Path project = copyOfDemoProject(); + Path css = project.resolve("src/main/css/theme.css"); + Files.write(css, ("Form { background-color: #ffffff; color: #172033; }\n" + + "Label, SpanLabel { color: #123456; }\n" + + "Title { color: #654321; }\n" + + "Button { color: #abcdef; }\n").getBytes(StandardCharsets.UTF_8)); + + CodenameOneGUIBuilder builder = workspace(project); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + settle(); + + assertEquals(0x123456, foreground(builder, "nestedDescription"), + "a Label on the canvas must take its colour from the project stylesheet"); + assertEquals(0xabcdef, foreground(builder, "nestedAction"), + "a Button on the canvas must take its colour from the project stylesheet"); + } + + @Test + void editingTheProjectStylesheetRestylesTheCanvas() throws Exception { + Path project = copyOfDemoProject(); + Path css = project.resolve("src/main/css/theme.css"); + Files.write(css, "Label, SpanLabel { color: #123456; }\n".getBytes(StandardCharsets.UTF_8)); + + CodenameOneGUIBuilder builder = workspace(project); + assertNull(onEdt(() -> builder.mcpOpenForm("com.example.NestedLayoutsForm"))); + settle(); + assertEquals(0x123456, foreground(builder, "nestedDescription"), "the starting colour must apply"); + + // pink is what a person actually types, and it exercises the named-colour path. + Files.write(css, "Label, SpanLabel { color: pink; }\n".getBytes(StandardCharsets.UTF_8)); + assertEquals("true", onEdt(() -> String.valueOf(builder.reloadProjectCssForTest())), + "recompiling the edited stylesheet failed"); + settle(); + + assertEquals(0xffc0cb, foreground(builder, "nestedDescription"), + "editing the stylesheet must restyle the canvas"); + } + + // ---- harness ---------------------------------------------------------------------------- + + private static int foreground(CodenameOneGUIBuilder builder, String name) { + Component preview = find(builder.canvasHostForTest(), name); + assertNotNull(preview, name + " does not render on the canvas"); + return preview.getUnselectedStyle().getFgColor(); + } + + private static Component find(Container root, String name) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component component = root.getComponentAt(i); + Object element = component.getClientProperty("gui.element"); + if (element instanceof Element && name.equals(((Element) element).getAttribute("name"))) return component; + if (component instanceof Container) { + Component nested = find(((Container) component), name); + if (nested != null) return nested; + } + } + return null; + } + + /** Works on a copy so a test never edits the checked-in demo project. */ + private static Path copyOfDemoProject() throws Exception { + Path source = new File("../demo-project").getCanonicalFile().toPath(); + Path target = Files.createTempDirectory("guibuilder-demo"); + try (Stream walk = Files.walk(source)) { + for (Path path : walk.toArray(Path[]::new)) { + if (path.toString().contains("/target/")) continue; + Path destination = target.resolve(source.relativize(path).toString()); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } + return target; + } + + private static CodenameOneGUIBuilder workspace(Path project) throws Exception { + Path input = Files.createTempFile("guibuilder", ".input"); + Files.write(input, ("projectDir=" + project + "\n" + + "guiDir=" + project.resolve("src/main/guibuilder") + "\n" + + "sourceDir=" + project.resolve("src/main/java") + "\n" + + "cssFile=" + project.resolve("src/main/css/theme.css") + "\n" + + "initialForm=com.example.NestedLayoutsForm\n").getBytes(StandardCharsets.UTF_8)); + input.toFile().deleteOnExit(); + System.setProperty("guibuilder.input", input.toString()); + System.setProperty("guibuilder.canvasMode", "desktop"); + CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + builder.init(null); + builder.runApp(); + settle(); + return builder; + } + + private static String onEdt(java.util.function.Supplier work) { + final String[] out = new String[1]; + Display.getInstance().callSeriallyAndWait(() -> out[0] = work.get()); + return out[0]; + } + + private static void settle() { + for (int i = 0; i < 5; i++) { + try { Thread.sleep(250); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + Display.getInstance().callSeriallyAndWait(() -> { }); + } + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectIoTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectIoTest.java new file mode 100644 index 00000000000..3e9f41857f0 --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectIoTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.guibuilder.project.ProjectIO; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Saving is the one operation in this editor that can destroy work that already exists on disk, so + * it is checked directly rather than through the designer. + */ +class ProjectIoTest { + @BeforeAll + static void initializeCodenameOneRuntime() { + if (!com.codename1.ui.Display.isInitialized()) com.codename1.ui.Display.init(new JPanel()); + } + + @Test + void writingReplacesTheFileAndLeavesNoTemporaryBehind(@TempDir Path dir) throws Exception { + File target = dir.resolve("Form.gui").toFile(); + ProjectIO.write(target.getAbsolutePath(), ""); + assertEquals("", read(target)); + + ProjectIO.write(target.getAbsolutePath(), ""); + assertEquals("", read(target), "the second save did not replace the first"); + + for (File file : dir.toFile().listFiles()) { + assertFalse(file.getName().endsWith(".cn1tmp"), + "a temporary file survived the save: " + file.getName()); + } + } + + /** + * The truncate-then-write that this replaced left an empty file behind when the write failed. + * The target must not even be touched until the replacement content is complete on disk. + */ + @Test + void aFailedWriteLeavesThePreviousContentIntact(@TempDir Path dir) throws Exception { + File target = dir.resolve("Form.gui").toFile(); + ProjectIO.write(target.getAbsolutePath(), ""); + + // A directory where the temporary file has to go makes openOutputStream fail the way a full + // disk would, after the editor has already decided to save. + assertTrue(new File(target.getAbsolutePath() + ".cn1tmp").mkdir()); + try { + ProjectIO.write(target.getAbsolutePath(), ""); + fail("the failed write reported success"); + } catch (Exception expected) { + // the save is expected to fail; what matters is the state it leaves behind + } + assertEquals("", read(target), + "a failed save destroyed the previously saved form"); + } + + @Test + void writingCreatesMissingParentDirectories(@TempDir Path dir) throws Exception { + File target = dir.resolve("com").resolve("example").resolve("Form.gui").toFile(); + ProjectIO.write(target.getAbsolutePath(), ""); + assertEquals("", read(target)); + } + + /** + * Every path in this editor arrives from the Maven plugin in the platform's own notation, so on + * Windows the separator arithmetic runs over backslashes unless the URL is normalized first. + */ + @Test + void windowsPathsBecomeUsableFileUrls() { + assertEquals("file://C:/Users/dev/app/src/main/guibuilder", + ProjectIO.fsUrl("C:\\Users\\dev\\app\\src\\main\\guibuilder")); + assertEquals("file:///home/dev/app", ProjectIO.fsUrl("/home/dev/app")); + assertEquals("file:///home/dev/app", ProjectIO.fsUrl("file:///home/dev/app")); + assertNull(ProjectIO.fsUrl(null)); + } + + private static String read(File file) throws Exception { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/SourceEditingTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/SourceEditingTest.java new file mode 100644 index 00000000000..22c7f2572ea --- /dev/null +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/SourceEditingTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One 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. Codename One 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 Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.guibuilder; + +import com.codename1.ui.TextInputClient; +import com.codename1.ui.TextInputConfig; +import com.codename1.ui.TextInputState; +import com.codename1.ui.editor.CodePureEditor; +import com.codename1.ui.editor.CodeView; +import com.codename1.ui.editor.EditorHost; +import javax.swing.JPanel; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SourceEditingTest { + @BeforeAll static void init() { + if (!com.codename1.ui.Display.isInitialized()) com.codename1.ui.Display.init(new JPanel()); + } + + private static final String SOURCE = + "// \n" + + "package com.example;\n" + + "public class F {\n" + + "// \n" + + "// \n" + + "\n" + + "// \n" + + "// \n}\n// \n"; + + @Test void typingInsideTheUserRegionIsAccepted() { + CodePureEditor editor = new CodePureEditor(new Host(), "java"); + CodeView view = (CodeView) editor.getView(); + editor.cmd("setText", SOURCE); + editor.cmd("setProtectedMarkers", "// \n// "); + int userOffset = SOURCE.indexOf("// ") + "// ".length() + 1; + view.replaceRange(userOffset, userOffset, "int typed = 1;"); + assertTrue(editor.query("getText", null).contains("int typed = 1;"), + "typing in the user region must work"); + } + + @Test void typingWhereTheCaretStartsIsAccepted() { + CodePureEditor editor = new CodePureEditor(new Host(), "java"); + CodeView view = (CodeView) editor.getView(); + editor.cmd("setText", SOURCE); + editor.cmd("setProtectedMarkers", "// \n// "); + // Offset 0 is where the caret sits until something moves it, and it is inside the first + // generated block. Typing there is silently dropped, which reads as a dead editor. + view.replaceRange(0, 0, "X"); + assertFalse(editor.query("getText", null).startsWith("X"), + "the generated region must stay protected"); + System.out.println("PROTECTED-AT-CARET-ZERO: generated region correctly rejects typing at offset 0"); + } + + private static final class Host implements EditorHost { + @Override + public boolean isTextInputSupported() { + return false; + } + + @Override + public Object startTextInput(TextInputClient client, TextInputConfig config) { + return null; + } + + @Override + public void updateTextInputState(Object handle, TextInputState state) { + } + + @Override + public void stopTextInput(Object handle) { + } + + @Override + public void editorChanged() { + } + + @Override + public void fireEditorEvent(String type, String value) { + } + } +} diff --git a/scripts/guibuilder/pom.xml b/scripts/guibuilder/pom.xml new file mode 100644 index 00000000000..b084f05102d --- /dev/null +++ b/scripts/guibuilder/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + com.codenameone.guibuilder + cn1-guibuilder + 8.0-SNAPSHOT + pom + Codename One GUI Builder + Modern, Maven-first visual editor for Codename One GUI forms. + https://www.codenameone.com + GPL v2 With Classpath Exceptionhttps://openjdk.java.net/legal/gplv2+ce.htmlrepo + + shaiShai Almogshai.almog@codenameone.com+4 + chenChen Fishbeinchen.fishbein@codenameone.com+4 + shannahSteve Hannahsteve.hannah@codenameone.com-8 + + https://github.com/codenameone/CodenameOnescm:git:git@github.com:codenameone/CodenameOne.git + common + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + + 1.8 + 1.8 + 1.8 + 3.11.0 + cn1-guibuilder + + + com.codenameonecodenameone-core${cn1.version} + com.codenameonecodenameone-javase${cn1.version} + + + com.codenameonecodenameone-maven-plugin${cn1.plugin.version} + org.apache.maven.pluginsmaven-compiler-plugin${maven-compiler-plugin.version} + org.apache.maven.pluginsmaven-surefire-plugin3.2.2 + org.apache.maven.pluginsmaven-jar-plugin3.3.0 + org.apache.maven.pluginsmaven-dependency-plugin3.6.1 + + com.codenameonecodenameone-maven-plugin${cn1.plugin.version} + + + javasecodename1.platformjavasetruejavase + guibuilder-central + org.apache.maven.pluginsmaven-source-plugin3.3.0attach-sourcesjar-no-fork + org.apache.maven.pluginsmaven-javadoc-plugin3.6.3nonefalseattach-javadocsjar + org.apache.maven.pluginsmaven-gpg-plugin1.5sign-artifactsverifysign${gpg.passphrase}--pinentry-modeloopback + org.sonatype.centralcentral-publishing-maven-plugin0.8.0truecentraltrue + + + diff --git a/scripts/guibuilder/tools/guibuilder-mcp-client.mjs b/scripts/guibuilder/tools/guibuilder-mcp-client.mjs new file mode 100644 index 00000000000..368273e7bcc --- /dev/null +++ b/scripts/guibuilder/tools/guibuilder-mcp-client.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +import net from "node:net"; +import readline from "node:readline"; + +const host = process.env.GUIBUILDER_MCP_HOST || "127.0.0.1"; +const port = Number(process.env.GUIBUILDER_MCP_PORT || process.argv[2] || 8765); +let nextId = 1; +const pending = new Map(); +let buffer = ""; + +const socket = net.createConnection({ host, port }); +socket.setEncoding("utf8"); +socket.on("data", chunk => { + buffer += chunk; + while (buffer.includes("\n")) { + const index = buffer.indexOf("\n"); + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (!line.trim()) continue; + // A malformed or truncated line used to throw out of the data handler and kill the client, + // taking every pending request with it. Report it and keep reading the stream. + let message; + try { + message = JSON.parse(line); + } catch (error) { + process.stderr.write(`MCP_BAD_LINE ${error.message}: ${line}\n`); + continue; + } + const handler = pending.get(message.id); + if (handler) { + pending.delete(message.id); + handler(message); + } else { + process.stdout.write(`${line}\n`); + } + } +}); + +function request(method, params = {}) { + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, resolve); + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, error => { + if (error) { + pending.delete(id); + reject(error); + } + }); + }); +} + +function toolText(response) { + const content = response?.result?.content; + if (!Array.isArray(content) || !content.length) return response; + const text = content[0]?.text; + if (typeof text !== "string") return response; + try { return JSON.parse(text); } catch { return text; } +} + +socket.on("connect", async () => { + const initialized = await request("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "guibuilder-local-client", version: "1.0" } + }); + process.stdout.write(`MCP_CONNECTED ${host}:${port} ${JSON.stringify(initialized.result?.serverInfo || {})}\n`); + socket.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`); + + const input = readline.createInterface({ input: process.stdin, terminal: false }); + let queue = Promise.resolve(); + input.on("line", line => { + queue = queue.then(async () => { + const trimmed = line.trim(); + if (!trimmed) return; + if (trimmed === "tools") { + process.stdout.write(`${JSON.stringify((await request("tools/list")).result)}\n`); + return; + } + if (trimmed === "quit" || trimmed === "exit") { + socket.end(); + return; + } + let command; + try { + command = JSON.parse(trimmed); + } catch { + process.stdout.write(`${JSON.stringify({ error: "Expected JSON: {tool, arguments}" })}\n`); + return; + } + const response = await request("tools/call", { + name: command.tool, + arguments: command.arguments || {} + }); + process.stdout.write(`${JSON.stringify(toolText(response))}\n`); + }).catch(error => process.stdout.write(`${JSON.stringify({ error: String(error) })}\n`)); + }); +}); + +socket.on("error", error => { + process.stderr.write(`MCP_CONNECT_ERROR ${error.message}\n`); + process.exitCode = 1; +});