diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index e2be5e7049e..8afa3d258f7 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -91,6 +91,8 @@
import com.codename1.ui.TextArea;
import com.codename1.ui.TextSelection;
import com.codename1.ui.Transform;
+import com.codename1.ui.accessibility.AccessibilityManager;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
import com.codename1.ui.animations.Animation;
import com.codename1.ui.animations.Transition;
import com.codename1.ui.events.ActionEvent;
@@ -11132,6 +11134,35 @@ public void announceForAccessibility(Component cmp, String text) {
// should override this method.
}
+ /// Called after the portable semantic tree changes. Platform ports should
+ /// invalidate their native virtual accessibility roots and request the latest
+ /// immutable snapshot with {@link #getAccessibilityTreeSnapshot()}.
+ public void accessibilityTreeChanged(int changeType) {
+ }
+
+ /// Returns the latest immutable semantic tree for the current form.
+ public AccessibilityTreeSnapshot getAccessibilityTreeSnapshot() {
+ return AccessibilityManager.getInstance().getCurrentSnapshot();
+ }
+
+ /// Dispatches an action from a native virtual accessibility node onto the EDT.
+ public boolean performAccessibilityAction(long nodeId, String actionId, Object argument) {
+ return AccessibilityManager.getInstance().performAction(nodeId, actionId, argument);
+ }
+
+ /// Returns true when this port exposes the portable virtual semantic tree.
+ public boolean isAccessibilityTreeSupported() {
+ return false;
+ }
+
+ /// Returns true when semantic invalidations should be projected eagerly.
+ /// Pull-based ports should override this to return true only while assistive
+ /// technology is active. Ports whose semantic projection must always remain
+ /// attached, such as web ARIA, may return true unconditionally.
+ public boolean isAccessibilityTreeUpdateRequired() {
+ return isAccessibilityTreeSupported();
+ }
+
/// Returns true if the user has selected larger type fonts in the system settings.
/// Default implementation returns false.
///
@@ -11154,6 +11185,56 @@ public float getLargerTextScale() {
return 1.0f;
}
+ /// Returns true when the user requests stronger foreground/background contrast.
+ public boolean isHighContrastEnabled() {
+ return false;
+ }
+
+ /// Returns true when the user requests that information isn't conveyed by color alone.
+ public boolean isDifferentiateWithoutColorEnabled() {
+ return false;
+ }
+
+ /// Returns the selected color-vision correction mode.
+ public com.codename1.ui.AccessibilityColorVisionDeficiency getColorVisionDeficiency() {
+ return com.codename1.ui.AccessibilityColorVisionDeficiency.UNKNOWN;
+ }
+
+ /// Returns true when the user requests reduced or disabled nonessential motion.
+ public boolean isReduceMotionEnabled() {
+ return false;
+ }
+
+ /// Returns true when the user requests reduced transparency and blur effects.
+ public boolean isReduceTransparencyEnabled() {
+ return false;
+ }
+
+ /// Returns true when the user requests heavier text weight.
+ public boolean isBoldTextEnabled() {
+ return false;
+ }
+
+ /// Returns true when the operating system is inverting displayed colors.
+ public boolean isInvertColorsEnabled() {
+ return false;
+ }
+
+ /// Returns true when the operating system requests a grayscale presentation.
+ public boolean isGrayscaleEnabled() {
+ return false;
+ }
+
+ /// Returns true when switches should include visible on/off labels.
+ public boolean isOnOffSwitchLabelsEnabled() {
+ return false;
+ }
+
+ /// Returns true when a screen reader or touch-exploration service is active.
+ public boolean isScreenReaderEnabled() {
+ return false;
+ }
+
/// Returns the stack trace from the exception on the given
/// thread. This API isn't supported on all platforms and may
/// return a blank string when unavailable.
diff --git a/CodenameOne/src/com/codename1/ui/AccessibilityColorVisionDeficiency.java b/CodenameOne/src/com/codename1/ui/AccessibilityColorVisionDeficiency.java
new file mode 100644
index 00000000000..272fb497a6e
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/AccessibilityColorVisionDeficiency.java
@@ -0,0 +1,44 @@
+/*
+ * 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;
+
+/// Describes a color-vision deficiency selected in the operating system or
+/// simulated by the Java SE simulator.
+///
+/// Applications should never infer that color alone is safe when this value is
+/// {@link #NONE} or {@link #UNKNOWN}. Use text, shape, iconography, or patterns
+/// in addition to color for every important distinction.
+public enum AccessibilityColorVisionDeficiency {
+ /// The platform reports that no color-vision correction is enabled.
+ NONE,
+ /// Reduced sensitivity to red light.
+ PROTANOPIA,
+ /// Reduced sensitivity to green light.
+ DEUTERANOPIA,
+ /// Reduced sensitivity to blue light.
+ TRITANOPIA,
+ /// Colors are presented as shades of gray.
+ MONOCHROMACY,
+ /// The platform doesn't expose the selected correction mode.
+ UNKNOWN
+}
diff --git a/CodenameOne/src/com/codename1/ui/Button.java b/CodenameOne/src/com/codename1/ui/Button.java
index 3d1549f9a3f..0246355d122 100644
--- a/CodenameOne/src/com/codename1/ui/Button.java
+++ b/CodenameOne/src/com/codename1/ui/Button.java
@@ -24,6 +24,7 @@
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.ui.events.ActionEvent;
import com.codename1.ui.events.ActionListener;
import com.codename1.ui.events.ActionSource;
@@ -992,7 +993,11 @@ public boolean isToggle() {
///
/// - `toggle`: the toggle to set
public void setToggle(boolean toggle) {
+ if (this.toggle == toggle) {
+ return;
+ }
this.toggle = toggle;
+ accessibilityChanged(AccessibilityManager.CHANGE_STRUCTURE | AccessibilityManager.CHANGE_STATE);
if (toggle && "CheckBox".equals(getUIID()) || "RadioButton".equals(getUIID())) {
setUIID("ToggleButton");
}
diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java
index ecabef3e823..f02c75e403d 100644
--- a/CodenameOne/src/com/codename1/ui/CN.java
+++ b/CodenameOne/src/com/codename1/ui/CN.java
@@ -1072,6 +1072,56 @@ public static Boolean isDarkMode() {
return Display.INSTANCE.isDarkMode();
}
+ /// Returns true when the user requests stronger foreground/background contrast.
+ public static boolean isHighContrastEnabled() {
+ return Display.INSTANCE.isHighContrastEnabled();
+ }
+
+ /// Returns true when the user requests that information isn't conveyed by color alone.
+ public static boolean isDifferentiateWithoutColorEnabled() {
+ return Display.INSTANCE.isDifferentiateWithoutColorEnabled();
+ }
+
+ /// Returns the selected color-vision correction mode.
+ public static AccessibilityColorVisionDeficiency getColorVisionDeficiency() {
+ return Display.INSTANCE.getColorVisionDeficiency();
+ }
+
+ /// Returns true when the user requests reduced or disabled nonessential motion.
+ public static boolean isReduceMotionEnabled() {
+ return Display.INSTANCE.isReduceMotionEnabled();
+ }
+
+ /// Returns true when the user requests reduced transparency and blur effects.
+ public static boolean isReduceTransparencyEnabled() {
+ return Display.INSTANCE.isReduceTransparencyEnabled();
+ }
+
+ /// Returns true when the user requests heavier text weight.
+ public static boolean isBoldTextEnabled() {
+ return Display.INSTANCE.isBoldTextEnabled();
+ }
+
+ /// Returns true when the operating system is inverting displayed colors.
+ public static boolean isInvertColorsEnabled() {
+ return Display.INSTANCE.isInvertColorsEnabled();
+ }
+
+ /// Returns true when the operating system requests a grayscale presentation.
+ public static boolean isGrayscaleEnabled() {
+ return Display.INSTANCE.isGrayscaleEnabled();
+ }
+
+ /// Returns true when switches should include visible on/off labels.
+ public static boolean isOnOffSwitchLabelsEnabled() {
+ return Display.INSTANCE.isOnOffSwitchLabelsEnabled();
+ }
+
+ /// Returns true when a screen reader or touch-exploration service is active.
+ public static boolean isScreenReaderEnabled() {
+ return Display.INSTANCE.isScreenReaderEnabled();
+ }
+
/// Override the default dark mode setting
///
/// #### Parameters
diff --git a/CodenameOne/src/com/codename1/ui/CheckBox.java b/CodenameOne/src/com/codename1/ui/CheckBox.java
index 5144a89f09f..4de2a09f6a4 100644
--- a/CodenameOne/src/com/codename1/ui/CheckBox.java
+++ b/CodenameOne/src/com/codename1/ui/CheckBox.java
@@ -24,6 +24,7 @@
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.cloud.BindTarget;
import com.codename1.ui.events.ActionEvent;
import com.codename1.ui.events.ActionListener;
@@ -173,6 +174,7 @@ public void setSelected(boolean selected) {
this.selected = selected;
if (changed) {
fireChangeEvent();
+ accessibilityChanged(AccessibilityManager.CHANGE_STATE);
}
repaint();
}
diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java
index 91e8f48fa9f..f8279458387 100644
--- a/CodenameOne/src/com/codename1/ui/Component.java
+++ b/CodenameOne/src/com/codename1/ui/Component.java
@@ -40,6 +40,8 @@
import com.codename1.ui.events.StyleListener;
import com.codename1.ui.geom.Dimension;
import com.codename1.ui.geom.Rectangle;
+import com.codename1.ui.accessibility.AccessibilityManager;
+import com.codename1.ui.accessibility.AccessibilityNode;
import com.codename1.ui.layouts.FlowLayout;
import com.codename1.ui.plaf.Border;
import com.codename1.ui.plaf.LookAndFeel;
@@ -424,6 +426,7 @@ static int rubberBandDecompress(int compressed, int dim) {
private EventDispatcher stateChangeListeners;
private String tooltip;
private String accessibilityText;
+ private AccessibilityNode semantics;
/// The native overlay object. Used in Javascript port for some components so that there is
/// an inivisible "native" peer overlaid on the component itself to catch events. E.g.
/// TextFields on iOS can't be programmatically focused except through a user-initiated event -
@@ -1089,7 +1092,11 @@ public int getX() {
///
/// - `x`: the current x coordinate of the components origin
public void setX(int x) {
+ boolean changed = bounds.getX() != x;
bounds.setX(x);
+ if (changed) {
+ accessibilityChanged(AccessibilityManager.CHANGE_BOUNDS);
+ }
if (Form.activePeerCount > 0) {
onParentPositionChange();
}
@@ -1132,7 +1139,11 @@ public int getY() {
///
/// - `y`: the current y coordinate of the components origin
public void setY(int y) {
+ boolean changed = bounds.getY() != y;
bounds.setY(y);
+ if (changed) {
+ accessibilityChanged(AccessibilityManager.CHANGE_BOUNDS);
+ }
if (Form.activePeerCount > 0) {
onParentPositionChange();
}
@@ -1173,7 +1184,11 @@ public boolean isVisible() {
///
/// - `visible`: true if component is visible; otherwise false
public void setVisible(boolean visible) {
+ if (this.visible == visible) {
+ return;
+ }
this.visible = visible;
+ accessibilityChanged(AccessibilityManager.CHANGE_STRUCTURE);
}
void getVisibleRect(Rectangle r, boolean init) {
@@ -1363,7 +1378,11 @@ public int getWidth() {
///
/// - #setPreferredSize
public void setWidth(int width) {
+ boolean changed = bounds.getSize().getWidth() != width;
bounds.getSize().setWidth(width);
+ if (changed) {
+ accessibilityChanged(AccessibilityManager.CHANGE_BOUNDS);
+ }
}
/// Gets the outer width of this component. This is the width of the component including horizontal margins.
@@ -1407,7 +1426,11 @@ public int getHeight() {
///
/// - #setPreferredSize
public void setHeight(int height) {
+ boolean changed = bounds.getSize().getHeight() != height;
bounds.getSize().setHeight(height);
+ if (changed) {
+ accessibilityChanged(AccessibilityManager.CHANGE_BOUNDS);
+ }
}
/// Gets the outer height of this component. This is the height of the component including vertical margins.
@@ -2038,6 +2061,7 @@ void setParent(Container parent) {
throw new IllegalArgumentException("Attempt to add self as parent");
}
this.parent = parent;
+ accessibilityChanged(AccessibilityManager.CHANGE_STRUCTURE);
}
/// Gets the "owner" of this component as set by `#setOwner(com.codename1.ui.Component)`.
@@ -2329,7 +2353,8 @@ public void setLabelForComponent(Label componentLabel) {
void focusGainedInternal() {
startComponentLableTicker();
String text = getAccessibilityText();
- if (text != null && text.length() > 0) {
+ if (text != null && text.length() > 0
+ && !Display.getInstance().isAccessibilityTreeSupported()) {
announceForAccessibility(text);
}
}
@@ -3556,6 +3581,9 @@ protected void setScrollX(int scrollX) {
// component back into view can't recurse infinitely (issue #5305).
int oldScrollX = this.scrollX;
this.scrollX = scrollXtmp;
+ if (oldScrollX != scrollXtmp) {
+ accessibilityChanged(AccessibilityManager.CHANGE_BOUNDS);
+ }
if (scrollListeners != null && oldScrollX != scrollXtmp) {
scrollListeners.fireScrollEvent(scrollXtmp, this.scrollY, oldScrollX, this.scrollY);
}
@@ -3581,6 +3609,7 @@ public int getScrollY() {
///
/// - `scrollY`: the Y position of the scrolling
protected void setScrollY(int scrollY) {
+ int oldAccessibilityScrollY = this.scrollY;
if (this.scrollY != scrollY) {
CodenameOneImplementation ci = Display.impl;
@@ -3599,6 +3628,9 @@ protected void setScrollY(int scrollY) {
scrollYtmp = Math.min(scrollYtmp, h);
scrollYtmp = Math.max(scrollYtmp, 0);
}
+ if (oldAccessibilityScrollY != scrollYtmp) {
+ accessibilityChanged(AccessibilityManager.CHANGE_BOUNDS);
+ }
if (isScrollableY()) {
if (Form.activePeerCount > 0) {
onParentPositionChange();
@@ -4188,7 +4220,11 @@ public boolean hasFocus() {
///
/// - #requestFocus
public void setFocus(boolean focused) {
+ if (this.focused == focused) {
+ return;
+ }
this.focused = focused;
+ accessibilityChanged(AccessibilityManager.CHANGE_FOCUS);
}
/// Returns the Component Form or null if this Component
@@ -8125,6 +8161,7 @@ public void setEnabled(boolean enabled) {
return;
}
this.enabled = enabled;
+ accessibilityChanged(AccessibilityManager.CHANGE_STATE);
repaint();
}
@@ -8964,6 +9001,9 @@ public void announceForAccessibility(String text) {
///
/// accessibility description or `null` if none
public String getAccessibilityText() {
+ if (semantics != null && semantics.getLabel() != null) {
+ return semantics.getLabel();
+ }
if (accessibilityText != null) {
return accessibilityText;
}
@@ -8991,6 +9031,46 @@ public String getAccessibilityText() {
/// - `text`: accessibility description
public void setAccessibilityText(String text) {
this.accessibilityText = text;
+ if (semantics != null) {
+ semantics.setLabel(text);
+ } else {
+ accessibilityChanged(AccessibilityManager.CHANGE_CONTENT);
+ }
+ }
+
+ /// Returns the portable semantic configuration for this component. The same
+ /// instance is retained for the lifetime of the component. Mutating it updates
+ /// VoiceOver, TalkBack, UI Automation, AT-SPI, Java accessibility, and web ARIA
+ /// through the active platform adapter.
+ ///
+ /// #### Returns
+ ///
+ /// this component's semantic node
+ public AccessibilityNode getSemantics() {
+ if (semantics == null) {
+ semantics = new AccessibilityNode(this);
+ if (accessibilityText != null) {
+ semantics.setLabel(accessibilityText);
+ }
+ }
+ return semantics;
+ }
+
+ /// Alias for {@link #getSemantics()} for APIs that use accessibility-node terminology.
+ public AccessibilityNode getAccessibilityNode() {
+ return getSemantics();
+ }
+
+ /// Marks this component's semantic representation dirty. Custom components
+ /// whose accessible value is computed dynamically can call this after state
+ /// changes that do not pass through a semantic setter.
+ public void accessibilityChanged() {
+ accessibilityChanged(AccessibilityManager.CHANGE_ALL);
+ }
+
+ /// Marks a specific portion of this component's semantic representation dirty.
+ public void accessibilityChanged(int changeType) {
+ AccessibilityManager.getInstance().invalidate(this, changeType);
}
/// #### Returns
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index 2960da32e62..7d9e9d6b675 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -59,6 +59,7 @@
import com.codename1.plugin.event.IsGalleryTypeSupportedEvent;
import com.codename1.plugin.event.OpenGalleryEvent;
import com.codename1.system.CrashReport;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.ui.animations.Animation;
import com.codename1.ui.animations.CommonTransitions;
import com.codename1.ui.animations.Transition;
@@ -633,6 +634,29 @@ public void announceForAccessibility(String text) {
announceForAccessibility(null, text);
}
+ /// Notifies the native port that the portable semantic tree changed. This is
+ /// primarily an internal bridge used by the accessibility subsystem.
+ ///
+ /// #### Parameters
+ ///
+ /// - `changeType`: bit mask of `AccessibilityManager.CHANGE_*` constants
+ public void accessibilityTreeChanged(int changeType) {
+ impl.accessibilityTreeChanged(changeType);
+ }
+
+ /// Returns true when the active port exposes lightweight components through
+ /// a native virtual accessibility tree.
+ public boolean isAccessibilityTreeSupported() {
+ return impl.isAccessibilityTreeSupported();
+ }
+
+ /// Returns true when the active port currently needs semantic changes to be
+ /// projected eagerly. This is an internal bridge used to avoid rebuilding
+ /// an unused accessibility tree from hot component setters.
+ public boolean isAccessibilityTreeUpdateRequired() {
+ return impl.isAccessibilityTreeUpdateRequired();
+ }
+
/// Returns the status of the show during edit flag
///
/// #### Returns
@@ -754,6 +778,57 @@ public float getLargerTextScale() {
return impl.getLargerTextScale();
}
+ /// Returns true when the user requests stronger foreground/background contrast.
+ public boolean isHighContrastEnabled() {
+ return impl.isHighContrastEnabled();
+ }
+
+ /// Returns true when the user requests that information isn't conveyed by color alone.
+ public boolean isDifferentiateWithoutColorEnabled() {
+ return impl.isDifferentiateWithoutColorEnabled();
+ }
+
+ /// Returns the selected color-vision correction, or {@link AccessibilityColorVisionDeficiency#UNKNOWN}
+ /// when the platform doesn't expose it.
+ public AccessibilityColorVisionDeficiency getColorVisionDeficiency() {
+ return impl.getColorVisionDeficiency();
+ }
+
+ /// Returns true when the user requests reduced or disabled nonessential motion.
+ public boolean isReduceMotionEnabled() {
+ return impl.isReduceMotionEnabled();
+ }
+
+ /// Returns true when the user requests reduced transparency and blur effects.
+ public boolean isReduceTransparencyEnabled() {
+ return impl.isReduceTransparencyEnabled();
+ }
+
+ /// Returns true when the user requests heavier text weight.
+ public boolean isBoldTextEnabled() {
+ return impl.isBoldTextEnabled();
+ }
+
+ /// Returns true when the operating system is inverting displayed colors.
+ public boolean isInvertColorsEnabled() {
+ return impl.isInvertColorsEnabled();
+ }
+
+ /// Returns true when the operating system requests a grayscale presentation.
+ public boolean isGrayscaleEnabled() {
+ return impl.isGrayscaleEnabled();
+ }
+
+ /// Returns true when switches should include visible on/off labels.
+ public boolean isOnOffSwitchLabelsEnabled() {
+ return impl.isOnOffSwitchLabelsEnabled();
+ }
+
+ /// Returns true when a screen reader or touch-exploration service is active.
+ public boolean isScreenReaderEnabled() {
+ return impl.isScreenReaderEnabled();
+ }
+
/// Checks if async stack traces are enabled. If enabled, the stack trace
/// at the point of `#callSerially(java.lang.Runnable)` calls will
/// be recorded, and logged in the case that there is an uncaught exception.
@@ -1730,6 +1805,8 @@ void setCurrentForm(Form newForm) {
lastKeyPressed = 0;
previousKeyPressed = 0;
newForm.onShowCompletedImpl();
+ AccessibilityManager.getInstance().invalidate(newForm,
+ AccessibilityManager.CHANGE_STRUCTURE | AccessibilityManager.CHANGE_PANE);
}
private boolean applyInitialWindowSize(Form form) {
diff --git a/CodenameOne/src/com/codename1/ui/Label.java b/CodenameOne/src/com/codename1/ui/Label.java
index 4ba6fd7b390..0e08ace3df5 100644
--- a/CodenameOne/src/com/codename1/ui/Label.java
+++ b/CodenameOne/src/com/codename1/ui/Label.java
@@ -23,6 +23,7 @@
*/
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.cloud.BindTarget;
import com.codename1.io.Log;
import com.codename1.ui.TextSelection.Char;
@@ -639,11 +640,15 @@ public String getText() {
/// - `text`: the string that the label presents.
@Override
public void setText(String text) {
+ String oldText = this.text;
widthAtLastCheck = -1;
this.text = text;
localize();
stringWidthUnselected = -1;
setShouldCalcPreferredSize(true);
+ if (oldText == null ? this.text != null : !oldText.equals(this.text)) {
+ accessibilityChanged(AccessibilityManager.CHANGE_CONTENT);
+ }
repaint();
}
diff --git a/CodenameOne/src/com/codename1/ui/List.java b/CodenameOne/src/com/codename1/ui/List.java
index 0467aa680da..ac24ac2851d 100644
--- a/CodenameOne/src/com/codename1/ui/List.java
+++ b/CodenameOne/src/com/codename1/ui/List.java
@@ -23,6 +23,7 @@
*/
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.compat.java.util.Objects;
import com.codename1.ui.animations.Motion;
import com.codename1.ui.events.ActionEvent;
@@ -447,6 +448,102 @@ public int size() {
return model.getSize();
}
+ /// Returns the bounds of a renderer-backed row for the portable virtual
+ /// accessibility tree. Bounds are relative to this list.
+ public Rectangle getAccessibilityItemBounds(int index, Rectangle out) {
+ if (out == null) {
+ throw new NullPointerException("out");
+ }
+ if (index < 0 || index >= size()) {
+ out.setBounds(0, 0, 0, 0);
+ return out;
+ }
+ int width = getWidth() - getStyle().getHorizontalPadding() - getSideGap();
+ calculateComponentPosition(index, width, out, getElementSize(false, true),
+ getElementSize(true, true), index <= getCurrentSelected());
+ return out;
+ }
+
+ /// Returns the accessible name produced by the list renderer for an item.
+ public String getAccessibilityItemText(int index) {
+ if (index < 0 || index >= size()) {
+ return null;
+ }
+ T value = model.getItemAt(index);
+ Component rendererComponent = renderer.getListCellRendererComponent(this, value, index,
+ index == getSelectedIndex());
+ String text = rendererComponent == null ? null : rendererComponent.getAccessibilityText();
+ if (text == null || text.length() == 0) {
+ text = value == null ? null : String.valueOf(value);
+ }
+ return text;
+ }
+
+ /// Returns the model indices that should currently be materialized as
+ /// virtual accessibility children. The result contains the visible window,
+ /// a small one-item navigation buffer on each side, and the selected item.
+ /// It is intentionally bounded by the viewport so building a semantic tree
+ /// for a large renderer-backed list doesn't instantiate every row renderer.
+ public int[] getAccessibilityVisibleItemIndices() {
+ int modelSize = size();
+ if (modelSize == 0) {
+ return new int[0];
+ }
+
+ Dimension rendererSize = getElementSize(false, true);
+ Style style = getStyle();
+ int itemExtent = orientation == HORIZONTAL ? rendererSize.getWidth() : rendererSize.getHeight();
+ int viewportExtent = orientation == HORIZONTAL
+ ? getWidth() - style.getHorizontalPadding()
+ : getHeight() - style.getVerticalPadding();
+ int stride = Math.max(1, itemExtent + itemGap);
+ int windowSize = Math.min(modelSize, Math.max(1, viewportExtent / stride + 3));
+ int selected = getSelectedIndex();
+ int[] result = new int[Math.min(modelSize, windowSize + 1)];
+ int count = 0;
+
+ if (fixedSelection > FIXED_NONE_BOUNDRY) {
+ int center = selected >= 0 && selected < modelSize ? selected : 0;
+ int start = center - windowSize / 2;
+ for (int offset = 0; offset < windowSize; offset++) {
+ int index = (start + offset) % modelSize;
+ if (index < 0) {
+ index += modelSize;
+ }
+ result[count++] = index;
+ }
+ } else {
+ int scroll = orientation == HORIZONTAL ? getScrollX() : getScrollY();
+ int padding = orientation == HORIZONTAL ? style.getPaddingLeftNoRTL() : style.getPaddingTop();
+ int first = Math.max(0, (scroll - padding) / stride - 1);
+ if (first + windowSize > modelSize) {
+ first = Math.max(0, modelSize - windowSize);
+ }
+ for (int index = first; index < first + windowSize; index++) {
+ result[count++] = index;
+ }
+ }
+
+ if (selected >= 0 && selected < modelSize && !containsIndex(result, count, selected)) {
+ result[count++] = selected;
+ }
+ if (count == result.length) {
+ return result;
+ }
+ int[] trimmed = new int[count];
+ System.arraycopy(result, 0, trimmed, 0, count);
+ return trimmed;
+ }
+
+ private boolean containsIndex(int[] indices, int length, int wanted) {
+ for (int i = 0; i < length; i++) {
+ if (indices[i] == wanted) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/// Returns the visual selection during a drag operation, otherwise equivalent to model.getSelectedIndex
///
/// #### Returns
@@ -515,7 +612,11 @@ public void setSelectedIndex(int index, boolean scrollToSelection) {
if (index < 0) {
throw new IllegalArgumentException("Selection index is negative:" + index);
}
+ int oldIndex = model.getSelectedIndex();
model.setSelectedIndex(index);
+ if (oldIndex != index) {
+ accessibilityChanged(AccessibilityManager.CHANGE_STATE | AccessibilityManager.CHANGE_VALUE);
+ }
if (!isInitialized()) {
Form f = getComponentForm();
if (f == null) {
diff --git a/CodenameOne/src/com/codename1/ui/RadioButton.java b/CodenameOne/src/com/codename1/ui/RadioButton.java
index 0b341d68492..6cf49607fd6 100644
--- a/CodenameOne/src/com/codename1/ui/RadioButton.java
+++ b/CodenameOne/src/com/codename1/ui/RadioButton.java
@@ -23,6 +23,7 @@
*/
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.cloud.BindTarget;
import com.codename1.ui.events.ActionEvent;
import com.codename1.ui.events.ActionListener;
@@ -255,6 +256,7 @@ void setSelectedImpl(boolean selected) {
this.selected = selected;
if (changed) {
fireChangeEvent();
+ accessibilityChanged(AccessibilityManager.CHANGE_STATE);
}
repaint();
}
diff --git a/CodenameOne/src/com/codename1/ui/Slider.java b/CodenameOne/src/com/codename1/ui/Slider.java
index 488b0c8cf20..7ff7ab3435f 100644
--- a/CodenameOne/src/com/codename1/ui/Slider.java
+++ b/CodenameOne/src/com/codename1/ui/Slider.java
@@ -23,6 +23,7 @@
*/
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.ui.events.ActionEvent;
import com.codename1.ui.events.ActionListener;
@@ -304,7 +305,11 @@ public int getProgress(ActionEvent evt) {
/// thumb sliders) can route their own input through the same value-set +
/// repaint pipeline as the built-in handlers. See #1523.
protected void setProgressInternal(int value) {
+ int oldValue = this.value;
this.value = value;
+ if (oldValue != value) {
+ accessibilityChanged(AccessibilityManager.CHANGE_VALUE);
+ }
if (renderValueOnTop || renderPercentageOnTop) {
super.setText(formattedValue(value));
} else {
diff --git a/CodenameOne/src/com/codename1/ui/Tabs.java b/CodenameOne/src/com/codename1/ui/Tabs.java
index e939fff6495..d7e4c17c6d8 100644
--- a/CodenameOne/src/com/codename1/ui/Tabs.java
+++ b/CodenameOne/src/com/codename1/ui/Tabs.java
@@ -23,6 +23,7 @@
*/
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.ui.animations.Motion;
import com.codename1.ui.events.ActionEvent;
import com.codename1.ui.events.ActionListener;
@@ -1300,6 +1301,7 @@ public void setSelectedIndex(int index, boolean slideToSelected) {
if (index == activeComponent) {
return;
}
+ accessibilityChanged(AccessibilityManager.CHANGE_STATE | AccessibilityManager.CHANGE_PANE);
// Snapshot the current tab bounds *before* we mutate state, so the
// animated indicator can tween from where it visibly is to the new
// selection's bounds.
diff --git a/CodenameOne/src/com/codename1/ui/TextArea.java b/CodenameOne/src/com/codename1/ui/TextArea.java
index bebfe7ec53b..261d3773f75 100644
--- a/CodenameOne/src/com/codename1/ui/TextArea.java
+++ b/CodenameOne/src/com/codename1/ui/TextArea.java
@@ -23,6 +23,7 @@
*/
package com.codename1.ui;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.cloud.BindTarget;
import com.codename1.compat.java.util.Objects;
import com.codename1.io.Log;
@@ -609,6 +610,7 @@ && textMightGrowByContent(old, text)) {
}
if (!Objects.equals(text, old)) {
fireDataChanged(DataChangedListener.CHANGED, -1);
+ accessibilityChanged(AccessibilityManager.CHANGE_VALUE | AccessibilityManager.CHANGE_CONTENT);
}
// while native editing we don't need the cursor animations
if (Display.getInstance().isNativeInputSupported() && Display.getInstance().isTextEditing(this)) {
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityAction.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityAction.java
new file mode 100644
index 00000000000..3333d3744f0
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityAction.java
@@ -0,0 +1,88 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.ui.Component;
+
+/// An action that assistive technology can invoke on a semantic node.
+public final class AccessibilityAction {
+ public static final String ACTIVATE = "activate";
+ public static final String LONG_PRESS = "longPress";
+ public static final String INCREMENT = "increment";
+ public static final String DECREMENT = "decrement";
+ public static final String SET_VALUE = "setValue";
+ public static final String SET_TEXT = "setText";
+ public static final String FOCUS = "focus";
+ public static final String DISMISS = "dismiss";
+ public static final String EXPAND = "expand";
+ public static final String COLLAPSE = "collapse";
+ public static final String SCROLL_FORWARD = "scrollForward";
+ public static final String SCROLL_BACKWARD = "scrollBackward";
+ public static final String COPY = "copy";
+ public static final String CUT = "cut";
+ public static final String PASTE = "paste";
+ public static final String SHOW_ON_SCREEN = "showOnScreen";
+ public static final String SET_SELECTION = "setSelection";
+ public static final String MOVE_CURSOR_FORWARD_BY_CHARACTER = "moveCursorForwardByCharacter";
+ public static final String MOVE_CURSOR_BACKWARD_BY_CHARACTER = "moveCursorBackwardByCharacter";
+ public static final String MOVE_CURSOR_FORWARD_BY_WORD = "moveCursorForwardByWord";
+ public static final String MOVE_CURSOR_BACKWARD_BY_WORD = "moveCursorBackwardByWord";
+
+ /// Handles an accessibility action on the Codename One EDT.
+ public interface Handler {
+ boolean perform(Component component, Object argument);
+ }
+
+ private final String id;
+ private final String label;
+ private final Handler handler;
+ private final boolean enabled;
+
+ public AccessibilityAction(String id, String label, Handler handler) {
+ this(id, label, handler, true);
+ }
+
+ public AccessibilityAction(String id, String label, Handler handler, boolean enabled) {
+ if (id == null || id.length() == 0) {
+ throw new IllegalArgumentException("Accessibility action id must not be empty");
+ }
+ this.id = id;
+ this.label = label;
+ this.handler = handler;
+ this.enabled = enabled;
+ }
+
+ public String getId() {
+ return id;
+ }
+ public String getLabel() {
+ return label;
+ }
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public boolean perform(Component component, Object argument) {
+ return enabled && handler != null && handler.perform(component, argument);
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityAssertions.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityAssertions.java
new file mode 100644
index 00000000000..4efdccfd37a
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityAssertions.java
@@ -0,0 +1,187 @@
+/*
+ * 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.accessibility;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/// Automated semantic-tree assertions suitable for unit tests and CI.
+public final class AccessibilityAssertions {
+ private AccessibilityAssertions() {
+ }
+
+ public static List audit(AccessibilityTreeSnapshot tree) {
+ List issues = new ArrayList();
+ Set identifiers = new HashSet();
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ auditNode(tree, node, identifiers, issues);
+ }
+ for (Long root : tree.getRootIds()) {
+ detectCycle(tree, root.longValue(), new HashSet(), new HashSet(), issues);
+ }
+ return issues;
+ }
+
+ public static void assertNoErrors(AccessibilityTreeSnapshot tree) {
+ List issues = audit(tree);
+ StringBuilder errors = new StringBuilder();
+ for (AccessibilityIssue issue : issues) {
+ if (issue.getSeverity() == AccessibilityIssue.Severity.ERROR) {
+ if (errors.length() > 0) {
+ errors.append('\n');
+ }
+ errors.append(issue.toString());
+ }
+ }
+ if (errors.length() > 0) {
+ throw new AssertionError(errors.toString());
+ }
+ }
+
+ public static void assertNoUnlabeledInteractiveNodes(AccessibilityTreeSnapshot tree) {
+ StringBuilder errors = new StringBuilder();
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ if (node.isFocusable() && empty(node.getLabel()) && empty(node.getValue())) {
+ if (errors.length() > 0) {
+ errors.append('\n');
+ }
+ errors.append("Interactive node ").append(node.getId()).append(" (")
+ .append(node.getRole()).append(") has no accessible name");
+ }
+ }
+ if (errors.length() > 0) {
+ throw new AssertionError(errors.toString());
+ }
+ }
+
+ private static void auditNode(AccessibilityTreeSnapshot tree, AccessibilityNodeSnapshot node,
+ Set identifiers, List issues) {
+ if (node.isFocusable() && empty(node.getLabel()) && empty(node.getValue())) {
+ error(issues, "unlabeled-interactive", "Interactive nodes require a label or value", node);
+ }
+ if (node.getRange() != null && !node.getRange().isValid()) {
+ error(issues, "invalid-range", "Range must satisfy min <= current <= max and step >= 0", node);
+ }
+ if (node.getRole() == AccessibilityRole.HEADING && node.getHeadingLevel() == 0) {
+ error(issues, "invalid-heading-level", "Heading levels are 1-based", node);
+ }
+ if (node.getChecked() != AccessibilityCheckedState.UNSPECIFIED
+ && node.getRole() != AccessibilityRole.CHECKBOX
+ && node.getRole() != AccessibilityRole.RADIO_BUTTON
+ && node.getRole() != AccessibilityRole.SWITCH
+ && node.getRole() != AccessibilityRole.TREE_ITEM
+ && node.getRole() != AccessibilityRole.MENU_ITEM) {
+ warning(issues, "checked-role", "Checked state is unusual for this role", node);
+ }
+ if (node.getExpanded() != null && node.getRole() != AccessibilityRole.TREE_ITEM
+ && node.getRole() != AccessibilityRole.BUTTON && node.getRole() != AccessibilityRole.MENU_ITEM
+ && node.getRole() != AccessibilityRole.COMBO_BOX && node.getRole() != AccessibilityRole.GENERIC) {
+ warning(issues, "expanded-role", "Expanded state is unusual for this role", node);
+ }
+ if (node.getCollectionItemInfo() != null) {
+ AccessibilityCollectionItemInfo item = node.getCollectionItemInfo();
+ if (item.getPositionInSet() == 0 || item.getSetSize() == 0
+ || item.getPositionInSet() > 0 && item.getSetSize() > 0
+ && item.getPositionInSet() > item.getSetSize()) {
+ error(issues, "invalid-collection-position", "Collection position and size must be 1-based and consistent", node);
+ }
+ }
+ if (node.getParentId() >= 0 && tree.getNode(node.getParentId()) == null) {
+ error(issues, "missing-parent", "Semantic parent does not exist", node);
+ }
+ if (node.getIdentifier() != null && !identifiers.add(node.getIdentifier())) {
+ error(issues, "duplicate-identifier", "Accessibility identifiers must be unique", node);
+ }
+ if (node.getBounds().getWidth() <= 0 || node.getBounds().getHeight() <= 0) {
+ warning(issues, "empty-bounds", "Node is fully clipped or has empty bounds", node);
+ }
+ Set actions = new HashSet();
+ for (AccessibilityAction action : node.getActions()) {
+ if (!actions.add(action.getId())) {
+ error(issues, "duplicate-action", "Action ids must be unique per node", node);
+ }
+ if (!isStandardAction(action.getId()) && empty(action.getLabel())) {
+ error(issues, "unlabeled-custom-action", "Custom accessibility actions require localized labels", node);
+ }
+ }
+ if ((node.getRole() == AccessibilityRole.DIALOG || node.getRole() == AccessibilityRole.ALERT)
+ && empty(node.getLabel()) && empty(node.getPaneTitle())) {
+ error(issues, "unnamed-dialog", "Dialogs require a label or pane title", node);
+ }
+ }
+
+ private static void detectCycle(AccessibilityTreeSnapshot tree, long id, Set visiting,
+ Set visited, List issues) {
+ Long boxed = Long.valueOf(id);
+ if (visited.contains(boxed)) {
+ return;
+ }
+ if (!visiting.add(boxed)) {
+ AccessibilityNodeSnapshot node = tree.getNode(id);
+ if (node != null) {
+ error(issues, "tree-cycle", "Semantic tree contains a cycle", node);
+ }
+ return;
+ }
+ AccessibilityNodeSnapshot node = tree.getNode(id);
+ if (node != null) {
+ for (Long child : node.getChildIds()) {
+ detectCycle(tree, child.longValue(), visiting, visited, issues);
+ }
+ }
+ visiting.remove(boxed);
+ visited.add(boxed);
+ }
+
+ private static boolean isStandardAction(String id) {
+ return AccessibilityAction.ACTIVATE.equals(id) || AccessibilityAction.LONG_PRESS.equals(id)
+ || AccessibilityAction.INCREMENT.equals(id) || AccessibilityAction.DECREMENT.equals(id)
+ || AccessibilityAction.SET_VALUE.equals(id) || AccessibilityAction.SET_TEXT.equals(id)
+ || AccessibilityAction.FOCUS.equals(id) || AccessibilityAction.DISMISS.equals(id)
+ || AccessibilityAction.EXPAND.equals(id) || AccessibilityAction.COLLAPSE.equals(id)
+ || AccessibilityAction.SCROLL_FORWARD.equals(id) || AccessibilityAction.SCROLL_BACKWARD.equals(id)
+ || AccessibilityAction.COPY.equals(id) || AccessibilityAction.CUT.equals(id)
+ || AccessibilityAction.PASTE.equals(id) || AccessibilityAction.SHOW_ON_SCREEN.equals(id)
+ || AccessibilityAction.SET_SELECTION.equals(id)
+ || AccessibilityAction.MOVE_CURSOR_FORWARD_BY_CHARACTER.equals(id)
+ || AccessibilityAction.MOVE_CURSOR_BACKWARD_BY_CHARACTER.equals(id)
+ || AccessibilityAction.MOVE_CURSOR_FORWARD_BY_WORD.equals(id)
+ || AccessibilityAction.MOVE_CURSOR_BACKWARD_BY_WORD.equals(id);
+ }
+
+ private static void error(List issues, String code, String message,
+ AccessibilityNodeSnapshot node) {
+ issues.add(new AccessibilityIssue(AccessibilityIssue.Severity.ERROR, code, message, node.getId()));
+ }
+
+ private static void warning(List issues, String code, String message,
+ AccessibilityNodeSnapshot node) {
+ issues.add(new AccessibilityIssue(AccessibilityIssue.Severity.WARNING, code, message, node.getId()));
+ }
+
+ private static boolean empty(String value) {
+ return value == null || value.length() == 0;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCheckedState.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCheckedState.java
new file mode 100644
index 00000000000..3d5fe63d714
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCheckedState.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.ui.accessibility;
+
+/// Checked state for checkable semantic nodes.
+public enum AccessibilityCheckedState {
+ UNSPECIFIED,
+ UNCHECKED,
+ CHECKED,
+ MIXED
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityChildProvider.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityChildProvider.java
new file mode 100644
index 00000000000..752c68ae20e
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityChildProvider.java
@@ -0,0 +1,32 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.ui.Component;
+import java.util.List;
+
+/// Supplies semantic children that are not represented by persistent lightweight
+/// components, such as renderer-backed list rows and table cells.
+public interface AccessibilityChildProvider {
+ List getAccessibilityChildren(Component host);
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCollectionInfo.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCollectionInfo.java
new file mode 100644
index 00000000000..fad9b59733c
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCollectionInfo.java
@@ -0,0 +1,62 @@
+/*
+ * 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.accessibility;
+
+/// Metadata describing a list, grid, table, or other collection.
+public final class AccessibilityCollectionInfo {
+ public static final int SELECTION_NONE = 0;
+ public static final int SELECTION_SINGLE = 1;
+ public static final int SELECTION_MULTIPLE = 2;
+
+ private final int rowCount;
+ private final int columnCount;
+ private final boolean hierarchical;
+ private final int selectionMode;
+
+ public AccessibilityCollectionInfo(int rowCount, int columnCount) {
+ this(rowCount, columnCount, false, SELECTION_NONE);
+ }
+
+ public AccessibilityCollectionInfo(int rowCount, int columnCount, boolean hierarchical, int selectionMode) {
+ this.rowCount = rowCount;
+ this.columnCount = columnCount;
+ this.hierarchical = hierarchical;
+ this.selectionMode = selectionMode;
+ }
+
+ public int getRowCount() {
+ return rowCount;
+ }
+
+ public int getColumnCount() {
+ return columnCount;
+ }
+
+ public boolean isHierarchical() {
+ return hierarchical;
+ }
+
+ public int getSelectionMode() {
+ return selectionMode;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCollectionItemInfo.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCollectionItemInfo.java
new file mode 100644
index 00000000000..75910fb0579
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityCollectionItemInfo.java
@@ -0,0 +1,76 @@
+/*
+ * 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.accessibility;
+
+/// Position and span metadata for an item in a semantic collection.
+public final class AccessibilityCollectionItemInfo {
+ private final int rowIndex;
+ private final int rowSpan;
+ private final int columnIndex;
+ private final int columnSpan;
+ private final int positionInSet;
+ private final int setSize;
+ private final int level;
+ private final boolean heading;
+
+ public AccessibilityCollectionItemInfo(int rowIndex, int columnIndex) {
+ this(rowIndex, 1, columnIndex, 1, -1, -1, -1, false);
+ }
+
+ public AccessibilityCollectionItemInfo(int rowIndex, int rowSpan, int columnIndex, int columnSpan,
+ int positionInSet, int setSize, int level, boolean heading) {
+ this.rowIndex = rowIndex;
+ this.rowSpan = rowSpan;
+ this.columnIndex = columnIndex;
+ this.columnSpan = columnSpan;
+ this.positionInSet = positionInSet;
+ this.setSize = setSize;
+ this.level = level;
+ this.heading = heading;
+ }
+
+ public int getRowIndex() {
+ return rowIndex;
+ }
+ public int getRowSpan() {
+ return rowSpan;
+ }
+ public int getColumnIndex() {
+ return columnIndex;
+ }
+ public int getColumnSpan() {
+ return columnSpan;
+ }
+ public int getPositionInSet() {
+ return positionInSet;
+ }
+ public int getSetSize() {
+ return setSize;
+ }
+ public int getLevel() {
+ return level;
+ }
+ public boolean isHeading() {
+ return heading;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityGrouping.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityGrouping.java
new file mode 100644
index 00000000000..26f200743e8
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityGrouping.java
@@ -0,0 +1,39 @@
+/*
+ * 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.accessibility;
+
+/// Controls how a component and its descendants participate in the semantic tree.
+public enum AccessibilityGrouping {
+ /// Infer exposure from the role, content, actions, and component type.
+ AUTO,
+ /// Expose this node and omit all semantic descendants.
+ LEAF,
+ /// Expose this node and its semantic descendants.
+ GROUP,
+ /// Expose a single node whose label and description include its descendants.
+ MERGE_DESCENDANTS,
+ /// Omit this node but promote its semantic children.
+ EXCLUDE,
+ /// Omit this node and its entire semantic subtree.
+ EXCLUDE_SUBTREE
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityInspector.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityInspector.java
new file mode 100644
index 00000000000..4c7b5c50e3f
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityInspector.java
@@ -0,0 +1,39 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.ui.Form;
+
+/// Read-only semantic-tree inspection entry point for tests and developer tools.
+public final class AccessibilityInspector {
+ private AccessibilityInspector() {
+ }
+
+ public static AccessibilityTreeSnapshot snapshot(Form form) {
+ return AccessibilityManager.getInstance().getSnapshot(form);
+ }
+
+ public static AccessibilityTreeSnapshot currentSnapshot() {
+ return AccessibilityManager.getInstance().getCurrentSnapshot();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityIssue.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityIssue.java
new file mode 100644
index 00000000000..cb05b8d6322
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityIssue.java
@@ -0,0 +1,58 @@
+/*
+ * 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.accessibility;
+
+/// A deterministic accessibility audit finding.
+public final class AccessibilityIssue {
+ public enum Severity { ERROR, WARNING }
+
+ private final Severity severity;
+ private final String code;
+ private final String message;
+ private final long nodeId;
+
+ public AccessibilityIssue(Severity severity, String code, String message, long nodeId) {
+ this.severity = severity;
+ this.code = code;
+ this.message = message;
+ this.nodeId = nodeId;
+ }
+
+ public Severity getSeverity() {
+ return severity;
+ }
+ public String getCode() {
+ return code;
+ }
+ public String getMessage() {
+ return message;
+ }
+ public long getNodeId() {
+ return nodeId;
+ }
+
+ @Override
+ public String toString() {
+ return severity + " " + code + " [node " + nodeId + "]: " + message;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityLiveRegion.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityLiveRegion.java
new file mode 100644
index 00000000000..2ce8d93e9f1
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityLiveRegion.java
@@ -0,0 +1,30 @@
+/*
+ * 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.accessibility;
+
+/// How changes to a semantic node should be announced without moving focus.
+public enum AccessibilityLiveRegion {
+ OFF,
+ POLITE,
+ ASSERTIVE
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityManager.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityManager.java
new file mode 100644
index 00000000000..9a58315a0d1
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityManager.java
@@ -0,0 +1,884 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.ui.Button;
+import com.codename1.ui.CheckBox;
+import com.codename1.ui.Component;
+import com.codename1.ui.Container;
+import com.codename1.ui.Dialog;
+import com.codename1.ui.Display;
+import com.codename1.ui.Form;
+import com.codename1.ui.Label;
+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.geom.Rectangle;
+import com.codename1.ui.table.Table;
+import com.codename1.ui.util.WeakHashMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/// Builds, caches, diffs, and dispatches actions for the portable semantic tree.
+///
+/// The tree is always constructed on the Codename One EDT. Native ports read the
+/// last immutable snapshot and post actions through {@link #performAction(long, String, Object)},
+/// so native accessibility threads never synchronously enter application code.
+public final class AccessibilityManager {
+ public static final int CHANGE_STRUCTURE = 1;
+ public static final int CHANGE_CONTENT = 2;
+ public static final int CHANGE_STATE = 4;
+ public static final int CHANGE_VALUE = 8;
+ public static final int CHANGE_BOUNDS = 16;
+ public static final int CHANGE_FOCUS = 32;
+ public static final int CHANGE_ACTIONS = 64;
+ public static final int CHANGE_LIVE_REGION = 128;
+ public static final int CHANGE_PANE = 256;
+ public static final int CHANGE_ALL = 0x7fffffff;
+
+ private static final AccessibilityManager INSTANCE = new AccessibilityManager();
+ private final Map componentIds = new WeakHashMap();
+ private final Map> virtualIds =
+ new WeakHashMap>();
+ private long nextId = 1;
+ private long generation;
+ private boolean dirty = true;
+ private boolean refreshScheduled;
+ private int pendingChanges = CHANGE_ALL;
+ private Form snapshotForm;
+ private AccessibilityTreeSnapshot snapshot = new AccessibilityTreeSnapshot(
+ 0, Collections.emptyList(), Collections.emptyMap());
+
+ private AccessibilityManager() {
+ }
+
+ public static AccessibilityManager getInstance() {
+ return INSTANCE;
+ }
+
+ public synchronized void invalidate(Component component, int changeType) {
+ dirty = true;
+ pendingChanges |= changeType;
+ try {
+ // Most mutations only need to make the cached snapshot stale. Ports
+ // that can pull the tree do so on demand, and ports such as Android
+ // opt into eager projection only while assistive technology is active.
+ // This keeps layout, scrolling, and text setters at O(1) when nobody
+ // is consuming the semantic tree.
+ if (!Display.getInstance().isAccessibilityTreeUpdateRequired()) {
+ return;
+ }
+ if (!refreshScheduled) {
+ refreshScheduled = true;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ int changes;
+ synchronized (AccessibilityManager.this) {
+ changes = pendingChanges;
+ }
+ getSnapshot(Display.getInstance().getCurrent());
+ synchronized (AccessibilityManager.this) {
+ refreshScheduled = false;
+ }
+ Display.getInstance().accessibilityTreeChanged(changes);
+ }
+ });
+ }
+ } catch (Throwable ignored) {
+ // Display may not be initialized yet while an application constructs its first form.
+ refreshScheduled = false;
+ }
+ }
+
+ public void invalidateAll() {
+ invalidate(null, CHANGE_ALL);
+ }
+
+ public AccessibilityTreeSnapshot getCurrentSnapshot() {
+ synchronized (this) {
+ if (!Display.getInstance().isEdt()) {
+ return snapshot;
+ }
+ }
+ return getSnapshot(Display.getInstance().getCurrent());
+ }
+
+ @SuppressWarnings("PMD.CompareObjectsWithEquals")
+ public synchronized AccessibilityTreeSnapshot getSnapshot(Form form) {
+ // Walking a live lightweight component hierarchy off the Codename One
+ // EDT is unsafe. Native bridges on other threads receive the last
+ // immutable snapshot; active bridges arrange eager refreshes on the EDT.
+ if (!Display.getInstance().isEdt()) {
+ return snapshot;
+ }
+ if (!dirty && form == snapshotForm) {
+ return snapshot;
+ }
+ if (form == null) {
+ snapshot = new AccessibilityTreeSnapshot(++generation, Collections.emptyList(),
+ Collections.emptyMap());
+ snapshotForm = null;
+ dirty = false;
+ pendingChanges = 0;
+ return snapshot;
+ }
+
+ List roots = new ArrayList();
+ resolveComponent(form, roots);
+ sortTree(roots);
+ List rootIds = new ArrayList();
+ LinkedHashMap nodes = new LinkedHashMap();
+ freeze(roots, -1, rootIds, nodes);
+ snapshot = new AccessibilityTreeSnapshot(++generation, rootIds, nodes);
+ snapshotForm = form;
+ dirty = false;
+ pendingChanges = 0;
+ return snapshot;
+ }
+
+ public synchronized int getPendingChanges() {
+ return pendingChanges;
+ }
+
+ public boolean performAction(long nodeId, String actionId, final Object argument) {
+ final AccessibilityNodeSnapshot node;
+ final AccessibilityAction action;
+ synchronized (this) {
+ node = snapshot.getNode(nodeId);
+ action = node == null ? null : node.getAction(actionId);
+ }
+ if (node == null || action == null || !action.isEnabled()) {
+ return false;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ action.perform(node.getComponent(), argument);
+ invalidate(node.getComponent(), CHANGE_STATE | CHANGE_VALUE | CHANGE_CONTENT);
+ }
+ });
+ return true;
+ }
+
+ private long idFor(Component component) {
+ Long id = componentIds.get(component);
+ if (id == null) {
+ id = Long.valueOf(nextId++);
+ componentIds.put(component, id);
+ }
+ return id.longValue();
+ }
+
+ private long idForVirtual(Component host, String path) {
+ Map hostIds = virtualIds.get(host);
+ if (hostIds == null) {
+ hostIds = new LinkedHashMap();
+ virtualIds.put(host, hostIds);
+ }
+ Long id = hostIds.get(path);
+ if (id == null) {
+ id = Long.valueOf(nextId++);
+ hostIds.put(path, id);
+ }
+ return id.longValue();
+ }
+
+ private void resolveComponent(Component component, List destination) {
+ if (component == null || (!(component instanceof Form) && !component.isVisible()) || component.isHidden(true)) {
+ return;
+ }
+ AccessibilityNode config = component.getSemantics();
+ AccessibilityGrouping grouping = config.getGrouping();
+ if (grouping == AccessibilityGrouping.EXCLUDE_SUBTREE) {
+ return;
+ }
+
+ BuildNode node = buildComponentNode(component, config);
+ List children = new ArrayList();
+ if (grouping != AccessibilityGrouping.LEAF && component instanceof Container) {
+ Container container = (Container) component;
+ for (int i = 0; i < container.getComponentCount(); i++) {
+ resolveComponent(container.getComponentAt(i), children);
+ }
+ }
+ if (grouping != AccessibilityGrouping.LEAF) {
+ addVirtualChildren(component, config, "custom", children);
+ if (component instanceof com.codename1.ui.List) {
+ addListChildren((com.codename1.ui.List) component, children);
+ }
+ }
+
+ boolean expose = shouldExpose(component, config, node);
+ if (grouping == AccessibilityGrouping.EXCLUDE) {
+ expose = false;
+ }
+ if (grouping == AccessibilityGrouping.MERGE_DESCENDANTS) {
+ String merged = collectLabels(children);
+ if (isEmpty(node.builder.label)) {
+ node.builder.label = merged;
+ } else if (!isEmpty(merged)) {
+ node.builder.description = join(node.builder.description, merged);
+ }
+ children.clear();
+ expose = true;
+ }
+ if (grouping == AccessibilityGrouping.LEAF || grouping == AccessibilityGrouping.GROUP) {
+ expose = true;
+ }
+
+ if (expose) {
+ node.children.addAll(children);
+ destination.add(node);
+ } else {
+ destination.addAll(children);
+ }
+ }
+
+ private BuildNode buildComponentNode(final Component component, AccessibilityNode config) {
+ BuildNode out = new BuildNode();
+ out.id = idFor(component);
+ out.builder.id = out.id;
+ out.builder.component = component;
+ out.builder.identifier = config.getIdentifier();
+ out.builder.label = firstNonEmpty(config.getLabel(), component.getAccessibilityText());
+ out.builder.hint = config.getHint();
+ out.builder.description = config.getDescription();
+ out.builder.value = config.getValue();
+ out.builder.validationError = config.getValidationError();
+ out.builder.paneTitle = config.getPaneTitle();
+ out.builder.roleDescription = config.getRoleDescription();
+ out.builder.role = config.getRole() == AccessibilityRole.NONE ? inferRole(component) : config.getRole();
+ out.builder.checked = inferChecked(component, config);
+ out.builder.liveRegion = config.getLiveRegion();
+ out.builder.range = config.getRange() == null ? inferRange(component) : config.getRange();
+ out.builder.collectionInfo =
+ config.getCollectionInfo() == null ? inferCollection(component) : config.getCollectionInfo();
+ out.builder.collectionItemInfo = config.getCollectionItemInfo() == null ? inferCollectionItem(component)
+ : config.getCollectionItemInfo();
+ out.builder.bounds = componentBounds(component);
+ out.builder.selected = config.getSelected();
+ if (out.builder.selected == null) {
+ applyInferredSelected(component, out.builder);
+ }
+ out.builder.expanded = config.getExpanded();
+ out.builder.enabled =
+ config.getEnabled() == null ? Boolean.valueOf(component.isEnabled()) : config.getEnabled();
+ out.builder.invalid = config.getInvalid();
+ out.builder.busy = config.getBusy();
+ out.builder.readOnly = config.getReadOnly();
+ out.builder.required = config.getRequired();
+ out.builder.multiline = config.getMultiline();
+ out.builder.obscured = config.getObscured();
+ applyInferredTextStates(component, out.builder);
+ out.builder.pressed = config.getPressed();
+ out.builder.current = config.getCurrent();
+ out.builder.modal = config.isModal() || component instanceof Dialog;
+ out.builder.focusable = component.isFocusable() || isInteractiveRole(out.builder.role);
+ out.builder.focused = component.hasFocus();
+ out.builder.headingLevel = config.getHeadingLevel();
+ out.builder.sortKey = config.getSortKey();
+ out.builder.traversalBefore = config.getTraversalBefore();
+ out.builder.traversalAfter = config.getTraversalAfter();
+ out.builder.actions.addAll(config.getActions());
+ addDefaultActions(component, out.builder);
+ if (component instanceof Form && isEmpty(out.builder.paneTitle)) {
+ out.builder.paneTitle = ((Form) component).getTitle();
+ }
+ out.component = component;
+ return out;
+ }
+
+ private void addVirtualChildren(Component host, AccessibilityNode config, String path,
+ List destination) {
+ List virtual = new ArrayList();
+ virtual.addAll(config.getChildren());
+ if (config.getChildProvider() != null) {
+ List provided = config.getChildProvider().getAccessibilityChildren(host);
+ if (provided != null) {
+ virtual.addAll(provided);
+ }
+ }
+ for (int i = 0; i < virtual.size(); i++) {
+ AccessibilityNode child = virtual.get(i);
+ String key = child.getVirtualKey();
+ if (isEmpty(key)) {
+ key = "index-" + i;
+ }
+ BuildNode resolved = buildVirtualNode(host, child, path + "/" + key);
+ addVirtualChildren(host, child, path + "/" + key, resolved.children);
+ destination.add(resolved);
+ }
+ }
+
+ private BuildNode buildVirtualNode(Component host, AccessibilityNode config, String path) {
+ BuildNode out = new BuildNode();
+ out.id = idForVirtual(host, path);
+ out.builder.id = out.id;
+ out.builder.component = host;
+ out.builder.virtualKey = config.getVirtualKey();
+ out.builder.identifier = config.getIdentifier();
+ out.builder.label = config.getLabel();
+ out.builder.hint = config.getHint();
+ out.builder.description = config.getDescription();
+ out.builder.value = config.getValue();
+ out.builder.validationError = config.getValidationError();
+ out.builder.paneTitle = config.getPaneTitle();
+ out.builder.roleDescription = config.getRoleDescription();
+ out.builder.role = config.getRole() == AccessibilityRole.NONE ? AccessibilityRole.GENERIC : config.getRole();
+ out.builder.checked = config.getChecked();
+ out.builder.liveRegion = config.getLiveRegion();
+ out.builder.range = config.getRange();
+ out.builder.collectionInfo = config.getCollectionInfo();
+ out.builder.collectionItemInfo = config.getCollectionItemInfo();
+ out.builder.bounds = virtualBounds(host, config.getBounds());
+ out.builder.selected = config.getSelected();
+ out.builder.expanded = config.getExpanded();
+ out.builder.enabled = config.getEnabled() == null ? Boolean.valueOf(host.isEnabled()) : config.getEnabled();
+ out.builder.invalid = config.getInvalid();
+ out.builder.busy = config.getBusy();
+ out.builder.readOnly = config.getReadOnly();
+ out.builder.required = config.getRequired();
+ out.builder.multiline = config.getMultiline();
+ out.builder.obscured = config.getObscured();
+ out.builder.pressed = config.getPressed();
+ out.builder.current = config.getCurrent();
+ out.builder.modal = config.isModal();
+ out.builder.focusable = !config.getActions().isEmpty() || isInteractiveRole(out.builder.role);
+ out.builder.headingLevel = config.getHeadingLevel();
+ out.builder.sortKey = config.getSortKey();
+ out.builder.actions.addAll(config.getActions());
+ out.component = host;
+ return out;
+ }
+
+ private void addListChildren(final com.codename1.ui.List list, List destination) {
+ int size = list.size();
+ int[] visibleItems = list.getAccessibilityVisibleItemIndices();
+ for (int i : visibleItems) {
+ final int index = i;
+ AccessibilityNode item = new AccessibilityNode("item-" + i)
+ .setRole(AccessibilityRole.LIST_ITEM)
+ .setLabel(list.getAccessibilityItemText(i))
+ .setSelected(Boolean.valueOf(i == list.getSelectedIndex()))
+ .setCollectionItemInfo(new AccessibilityCollectionItemInfo(
+ i, 1, 0, 1, i + 1, size, 1, false))
+ .setBounds(list.getAccessibilityItemBounds(i, new Rectangle()))
+ .addAction(new AccessibilityAction(AccessibilityAction.ACTIVATE, null,
+ new ListActivateHandler(list, index)));
+ destination.add(buildVirtualNode(list, item, "list/item-" + i));
+ }
+ }
+
+ private void addDefaultActions(final Component component, AccessibilityNodeSnapshot.Builder builder) {
+ if (component.isFocusable() && !hasAction(builder.actions, AccessibilityAction.FOCUS)) {
+ builder.actions.add(new AccessibilityAction(AccessibilityAction.FOCUS, null, FocusHandler.INSTANCE));
+ }
+ if (component instanceof Button && !hasAction(builder.actions, AccessibilityAction.ACTIVATE)) {
+ builder.actions.add(new AccessibilityAction(AccessibilityAction.ACTIVATE, null, ActivateHandler.INSTANCE));
+ }
+ if (component instanceof Slider) {
+ final Slider slider = (Slider) component;
+ if (slider.isEditable() && !hasAction(builder.actions, AccessibilityAction.INCREMENT)) {
+ builder.actions.add(new AccessibilityAction(AccessibilityAction.INCREMENT, null,
+ new SliderAdjustmentHandler(slider, 1)));
+ }
+ if (slider.isEditable() && !hasAction(builder.actions, AccessibilityAction.DECREMENT)) {
+ builder.actions.add(new AccessibilityAction(AccessibilityAction.DECREMENT, null,
+ new SliderAdjustmentHandler(slider, -1)));
+ }
+ }
+ if (component instanceof com.codename1.ui.List) {
+ final com.codename1.ui.List list = (com.codename1.ui.List) component;
+ if (list.size() > 0 && !hasAction(builder.actions, AccessibilityAction.SCROLL_FORWARD)) {
+ builder.actions.add(new AccessibilityAction(AccessibilityAction.SCROLL_FORWARD, null,
+ new ListScrollHandler(list, 1)));
+ }
+ if (list.size() > 0 && !hasAction(builder.actions, AccessibilityAction.SCROLL_BACKWARD)) {
+ builder.actions.add(new AccessibilityAction(AccessibilityAction.SCROLL_BACKWARD, null,
+ new ListScrollHandler(list, -1)));
+ }
+ }
+ if (component instanceof TextArea) {
+ final TextArea text = (TextArea) component;
+ if (!hasAction(builder.actions, AccessibilityAction.FOCUS)) {
+ builder.actions.add(new AccessibilityAction(AccessibilityAction.FOCUS, null, FocusHandler.INSTANCE));
+ }
+ if (text.isEditable() && !hasAction(builder.actions, AccessibilityAction.SET_TEXT)) {
+ builder.actions.add(
+ new AccessibilityAction(AccessibilityAction.SET_TEXT, null, new SetTextHandler(text)));
+ }
+ }
+ }
+
+ private AccessibilityRole inferRole(Component component) {
+ if (component.getParent() instanceof Table) {
+ return ((Table) component.getParent()).getCellRow(component) < 0 ? AccessibilityRole.COLUMN_HEADER
+ : AccessibilityRole.CELL;
+ }
+ Tabs tabOwner = tabPanelOwner(component);
+ if (tabOwner != null) {
+ return AccessibilityRole.TAB_PANEL;
+ }
+ if (component instanceof Dialog) {
+ return AccessibilityRole.DIALOG;
+ }
+ if (component instanceof RadioButton) {
+ return AccessibilityRole.RADIO_BUTTON;
+ }
+ if (component instanceof CheckBox) {
+ return AccessibilityRole.CHECKBOX;
+ }
+ if (component instanceof Button) {
+ if (isTabButton((Button) component)) {
+ return AccessibilityRole.TAB;
+ }
+ return ((Button) component).isToggle() ? AccessibilityRole.TOGGLE_BUTTON : AccessibilityRole.BUTTON;
+ }
+ if (component instanceof Slider) {
+ return ((Slider) component).isEditable() ? AccessibilityRole.SLIDER : AccessibilityRole.PROGRESS_BAR;
+ }
+ if (component instanceof TextField) {
+ return AccessibilityRole.TEXT_FIELD;
+ }
+ if (component instanceof TextArea) {
+ return AccessibilityRole.TEXT_FIELD;
+ }
+ if (component instanceof com.codename1.ui.List) {
+ return AccessibilityRole.LIST;
+ }
+ if (component instanceof Table) {
+ return AccessibilityRole.GRID;
+ }
+ if (component instanceof Tabs) {
+ return AccessibilityRole.TAB_LIST;
+ }
+ if (component instanceof Form) {
+ return AccessibilityRole.GENERIC;
+ }
+ if (component instanceof Label) {
+ return AccessibilityRole.STATIC_TEXT;
+ }
+ return AccessibilityRole.NONE;
+ }
+
+ @SuppressWarnings("PMD.CompareObjectsWithEquals")
+ private boolean isTabButton(Button button) {
+ Container parent = button.getParent();
+ while (parent != null) {
+ Container owner = parent.getParent();
+ if (owner instanceof Tabs && ((Tabs) owner).getTabsContainer() == parent) {
+ return true;
+ }
+ parent = parent.getParent();
+ }
+ return false;
+ }
+
+ private AccessibilityCheckedState inferChecked(Component component, AccessibilityNode config) {
+ if (config.getChecked() != AccessibilityCheckedState.UNSPECIFIED) {
+ return config.getChecked();
+ }
+ if (component instanceof CheckBox || component instanceof RadioButton) {
+ return ((Button) component).isSelected() ? AccessibilityCheckedState.CHECKED
+ : AccessibilityCheckedState.UNCHECKED;
+ }
+ return AccessibilityCheckedState.UNSPECIFIED;
+ }
+
+ @SuppressWarnings("PMD.CompareObjectsWithEquals")
+ private void applyInferredSelected(Component component, AccessibilityNodeSnapshot.Builder builder) {
+ if (component instanceof Button && (((Button) component).isToggle() || isTabButton((Button) component))) {
+ builder.selected = Boolean.valueOf(((Button) component).isSelected());
+ return;
+ }
+ Tabs tabOwner = tabPanelOwner(component);
+ if (tabOwner != null) {
+ builder.selected = Boolean.valueOf(tabOwner.getSelectedComponent() == component);
+ }
+ }
+
+ @SuppressWarnings("PMD.CompareObjectsWithEquals")
+ private Tabs tabPanelOwner(Component component) {
+ Container parent = component.getParent();
+ if (parent != null && parent.getParent() instanceof Tabs) {
+ Tabs tabs = (Tabs) parent.getParent();
+ if (tabs.getContentPane() == parent) {
+ return tabs;
+ }
+ }
+ return null;
+ }
+
+ private AccessibilityRange inferRange(Component component) {
+ if (component instanceof Slider) {
+ Slider slider = (Slider) component;
+ return new AccessibilityRange(slider.getMinValue(), slider.getMaxValue(), slider.getProgress(),
+ slider.getIncrements(), null);
+ }
+ return null;
+ }
+
+ private AccessibilityCollectionInfo inferCollection(Component component) {
+ if (component instanceof com.codename1.ui.List) {
+ return new AccessibilityCollectionInfo(((com.codename1.ui.List) component).size(), 1, false,
+ AccessibilityCollectionInfo.SELECTION_SINGLE);
+ }
+ if (component instanceof Table) {
+ Table table = (Table) component;
+ return new AccessibilityCollectionInfo(table.getModel().getRowCount(), table.getModel().getColumnCount(),
+ false, AccessibilityCollectionInfo.SELECTION_SINGLE);
+ }
+ if (component instanceof Tabs) {
+ return new AccessibilityCollectionInfo(1, ((Tabs) component).getTabCount(), false,
+ AccessibilityCollectionInfo.SELECTION_SINGLE);
+ }
+ return null;
+ }
+
+ private AccessibilityCollectionItemInfo inferCollectionItem(Component component) {
+ if (component.getParent() instanceof Table) {
+ Table table = (Table) component.getParent();
+ int sourceRow = table.getCellRow(component);
+ int row = sourceRow < 0 ? 0 : sourceRow + (table.isIncludeHeader() ? 1 : 0);
+ int column = table.getCellColumn(component);
+ return new AccessibilityCollectionItemInfo(row, 1, column, 1, column + 1, table.getModel().getColumnCount(),
+ 1, sourceRow < 0);
+ }
+ return null;
+ }
+
+ private void applyInferredTextStates(Component component, AccessibilityNodeSnapshot.Builder builder) {
+ if (!(component instanceof TextArea)) {
+ return;
+ }
+ TextArea text = (TextArea) component;
+ if (builder.readOnly == null) {
+ builder.readOnly = Boolean.valueOf(!text.isEditable());
+ }
+ if (builder.multiline == null) {
+ builder.multiline = Boolean.valueOf(!(component instanceof TextField) || text.getRows() > 1);
+ }
+ if (builder.obscured == null) {
+ builder.obscured = Boolean.valueOf((text.getConstraint() & TextArea.PASSWORD) != 0);
+ }
+ }
+
+ private boolean shouldExpose(Component component, AccessibilityNode config, BuildNode node) {
+ if (component instanceof Form) {
+ return true;
+ }
+ if (config.hasExplicitConfiguration()) {
+ return true;
+ }
+ return node.builder.role != AccessibilityRole.NONE &&
+ (!isEmpty(node.builder.label) || !isEmpty(node.builder.value) || node.builder.focusable ||
+ !node.builder.actions.isEmpty() || node.builder.collectionInfo != null || node.builder.range != null);
+ }
+
+ private Rectangle componentBounds(Component component) {
+ Rectangle bounds = new Rectangle(component.getAbsoluteX() + component.getScrollX(),
+ component.getAbsoluteY() + component.getScrollY(), component.getWidth(),
+ component.getHeight());
+ Container parent = component.getParent();
+ while (parent != null && bounds.getWidth() > 0 && bounds.getHeight() > 0) {
+ Rectangle clip =
+ new Rectangle(parent.getAbsoluteX() + parent.getScrollX(),
+ parent.getAbsoluteY() + parent.getScrollY(), parent.getWidth(), parent.getHeight());
+ intersect(bounds, clip);
+ parent = parent.getParent();
+ }
+ return bounds;
+ }
+
+ private Rectangle virtualBounds(Component host, Rectangle relative) {
+ if (relative == null) {
+ return componentBounds(host);
+ }
+ Rectangle bounds = new Rectangle(host.getAbsoluteX() + host.getScrollX() + relative.getX(),
+ host.getAbsoluteY() + host.getScrollY() + relative.getY(), relative.getWidth(),
+ relative.getHeight());
+ intersect(bounds, componentBounds(host));
+ return bounds;
+ }
+
+ private void intersect(Rectangle target, Rectangle clip) {
+ int x1 = Math.max(target.getX(), clip.getX());
+ int y1 = Math.max(target.getY(), clip.getY());
+ int x2 = Math.min(target.getX() + target.getWidth(), clip.getX() + clip.getWidth());
+ int y2 = Math.min(target.getY() + target.getHeight(), clip.getY() + clip.getHeight());
+ target.setBounds(x1, y1, Math.max(0, x2 - x1), Math.max(0, y2 - y1));
+ }
+
+ private void sortTree(List nodes) {
+ Collections.sort(nodes, SortKeyComparator.INSTANCE);
+ applyRelativeOrder(nodes);
+ for (BuildNode node : nodes) {
+ sortTree(node.children);
+ }
+ }
+
+ private void applyRelativeOrder(List nodes) {
+ int remainingPasses = nodes.size();
+ while (remainingPasses > 0) {
+ boolean changed = false;
+ for (int i = 0; i < nodes.size(); i++) {
+ BuildNode node = nodes.get(i);
+ int target = indexOf(nodes, node.builder.traversalBefore);
+ if (target >= 0 && i > target) {
+ nodes.remove(i);
+ nodes.add(target, node);
+ changed = true;
+ break;
+ }
+ target = indexOf(nodes, node.builder.traversalAfter);
+ if (target >= 0 && i < target) {
+ nodes.remove(i);
+ nodes.add(Math.min(target, nodes.size()), node);
+ changed = true;
+ break;
+ }
+ }
+ if (!changed) {
+ return;
+ }
+ remainingPasses--;
+ }
+ }
+
+ @SuppressWarnings("PMD.CompareObjectsWithEquals")
+ private int indexOf(List nodes, Component component) {
+ if (component == null) {
+ return -1;
+ }
+ for (int i = 0; i < nodes.size(); i++) {
+ if (nodes.get(i).component == component) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void freeze(List source, long parentId, List childIds,
+ LinkedHashMap nodes) {
+ for (BuildNode node : source) {
+ node.builder.parentId = parentId;
+ for (BuildNode child : node.children) {
+ node.builder.childIds.add(Long.valueOf(child.id));
+ }
+ AccessibilityNodeSnapshot frozen = new AccessibilityNodeSnapshot(node.builder);
+ nodes.put(Long.valueOf(node.id), frozen);
+ childIds.add(Long.valueOf(node.id));
+ List ignored = new ArrayList();
+ freeze(node.children, node.id, ignored, nodes);
+ }
+ }
+
+ private String collectLabels(List nodes) {
+ String value = null;
+ for (BuildNode node : nodes) {
+ value = join(value, node.builder.label);
+ value = join(value, collectLabels(node.children));
+ }
+ return value;
+ }
+
+ private static boolean hasAction(List actions, String id) {
+ for (AccessibilityAction action : actions) {
+ if (id.equals(action.getId())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isInteractiveRole(AccessibilityRole role) {
+ return role == AccessibilityRole.BUTTON || role == AccessibilityRole.TOGGLE_BUTTON ||
+ role == AccessibilityRole.CHECKBOX || role == AccessibilityRole.RADIO_BUTTON ||
+ role == AccessibilityRole.SWITCH || role == AccessibilityRole.LINK ||
+ role == AccessibilityRole.TEXT_FIELD || role == AccessibilityRole.SEARCH_FIELD ||
+ role == AccessibilityRole.SLIDER || role == AccessibilityRole.TAB ||
+ role == AccessibilityRole.MENU_ITEM || role == AccessibilityRole.SPIN_BUTTON ||
+ role == AccessibilityRole.COMBO_BOX || role == AccessibilityRole.TREE_ITEM;
+ }
+
+ private static String firstNonEmpty(String first, String second) {
+ return !isEmpty(first) ? first : second;
+ }
+
+ private static String join(String first, String second) {
+ if (isEmpty(first)) {
+ return second;
+ }
+ if (isEmpty(second)) {
+ return first;
+ }
+ return first + ", " + second;
+ }
+
+ private static boolean isEmpty(String value) {
+ return value == null || value.length() == 0;
+ }
+
+ private static final class BuildNode {
+ long id;
+ Component component;
+ AccessibilityNodeSnapshot.Builder builder = new AccessibilityNodeSnapshot.Builder();
+ List children = new ArrayList();
+ }
+
+ private static final class SortKeyComparator implements Comparator {
+ private static final SortKeyComparator INSTANCE = new SortKeyComparator();
+
+ @Override
+ public int compare(BuildNode a, BuildNode b) {
+ boolean an = Double.isNaN(a.builder.sortKey);
+ boolean bn = Double.isNaN(b.builder.sortKey);
+ if (an && bn) {
+ return 0;
+ }
+ if (an) {
+ return 1;
+ }
+ if (bn) {
+ return -1;
+ }
+ return Double.compare(a.builder.sortKey, b.builder.sortKey);
+ }
+ }
+
+ private static final class FocusHandler implements AccessibilityAction.Handler {
+ private static final FocusHandler INSTANCE = new FocusHandler();
+
+ @Override
+ public boolean perform(Component source, Object argument) {
+ if (!source.isEnabled()) {
+ return false;
+ }
+ source.requestFocus();
+ return true;
+ }
+ }
+
+ private static final class ActivateHandler implements AccessibilityAction.Handler {
+ private static final ActivateHandler INSTANCE = new ActivateHandler();
+
+ @Override
+ public boolean perform(Component source, Object argument) {
+ if (!source.isEnabled()) {
+ return false;
+ }
+ source.keyReleased(Display.getInstance().getKeyCode(Display.GAME_FIRE));
+ return true;
+ }
+ }
+
+ private static final class ListActivateHandler implements AccessibilityAction.Handler {
+ private final com.codename1.ui.List list;
+ private final int index;
+
+ private ListActivateHandler(com.codename1.ui.List list, int index) {
+ this.list = list;
+ this.index = index;
+ }
+
+ @Override
+ public boolean perform(Component source, Object argument) {
+ if (!list.isEnabled()) {
+ return false;
+ }
+ list.setSelectedIndex(index);
+ list.keyReleased(Display.getInstance().getKeyCode(Display.GAME_FIRE));
+ return true;
+ }
+ }
+
+ private static final class ListScrollHandler implements AccessibilityAction.Handler {
+ private final com.codename1.ui.List list;
+ private final int direction;
+
+ private ListScrollHandler(com.codename1.ui.List list, int direction) {
+ this.list = list;
+ this.direction = direction;
+ }
+
+ @Override
+ public boolean perform(Component source, Object argument) {
+ if (!list.isEnabled() || list.size() == 0) {
+ return false;
+ }
+ int selected = list.getSelectedIndex();
+ if (selected < 0) {
+ selected = direction > 0 ? 0 : list.size() - 1;
+ } else {
+ selected = Math.max(0, Math.min(list.size() - 1, selected + direction));
+ }
+ if (selected == list.getSelectedIndex()) {
+ return false;
+ }
+ list.setSelectedIndex(selected);
+ return true;
+ }
+ }
+
+ private static final class SliderAdjustmentHandler implements AccessibilityAction.Handler {
+ private final Slider slider;
+ private final int direction;
+
+ private SliderAdjustmentHandler(Slider slider, int direction) {
+ this.slider = slider;
+ this.direction = direction;
+ }
+
+ @Override
+ public boolean perform(Component source, Object argument) {
+ int increment = Math.max(1, slider.getIncrements());
+ int value = slider.getProgress() + direction * increment;
+ slider.setProgress(Math.max(slider.getMinValue(), Math.min(slider.getMaxValue(), value)));
+ return true;
+ }
+ }
+
+ private static final class SetTextHandler implements AccessibilityAction.Handler {
+ private final TextArea text;
+
+ private SetTextHandler(TextArea text) {
+ this.text = text;
+ }
+
+ @Override
+ public boolean perform(Component source, Object argument) {
+ if (!(argument instanceof String)) {
+ return false;
+ }
+ text.setText((String) argument);
+ return true;
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityNode.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityNode.java
new file mode 100644
index 00000000000..3668f0b799b
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityNode.java
@@ -0,0 +1,397 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.ui.Component;
+import com.codename1.ui.geom.Rectangle;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/// Mutable semantic configuration for a lightweight component or virtual child.
+///
+/// Component-owned nodes are obtained with {@link Component#getSemantics()}.
+/// Applications can create standalone nodes for an {@link AccessibilityChildProvider};
+/// virtual nodes must have a stable {@link #setVirtualKey(java.lang.String) virtual key}.
+public class AccessibilityNode {
+ private Component owner;
+ private String virtualKey;
+ private String identifier;
+ private String label;
+ private String hint;
+ private String description;
+ private String value;
+ private String validationError;
+ private String paneTitle;
+ private String roleDescription;
+ private AccessibilityRole role = AccessibilityRole.NONE;
+ private AccessibilityCheckedState checked = AccessibilityCheckedState.UNSPECIFIED;
+ private AccessibilityLiveRegion liveRegion = AccessibilityLiveRegion.OFF;
+ private AccessibilityGrouping grouping = AccessibilityGrouping.AUTO;
+ private AccessibilityRange range;
+ private AccessibilityCollectionInfo collectionInfo;
+ private AccessibilityCollectionItemInfo collectionItemInfo;
+ private AccessibilityChildProvider childProvider;
+ private Rectangle bounds;
+ private Boolean selected;
+ private Boolean expanded;
+ private Boolean enabled;
+ private Boolean invalid;
+ private Boolean busy;
+ private Boolean readOnly;
+ private Boolean required;
+ private Boolean multiline;
+ private Boolean obscured;
+ private Boolean pressed;
+ private Boolean current;
+ private boolean modal;
+ private int headingLevel = -1;
+ private double sortKey = Double.NaN;
+ private Component traversalBefore;
+ private Component traversalAfter;
+ private final List actions = new ArrayList();
+ private final List children = new ArrayList();
+
+ public AccessibilityNode() {
+ }
+
+ public AccessibilityNode(String virtualKey) {
+ setVirtualKey(virtualKey);
+ }
+
+ /// Internal constructor used by {@link Component}.
+ public AccessibilityNode(Component owner) {
+ this.owner = owner;
+ }
+
+ private AccessibilityNode changed(int type) {
+ if (owner != null) {
+ AccessibilityManager.getInstance().invalidate(owner, type);
+ }
+ return this;
+ }
+
+ public Component getOwner() {
+ return owner;
+ }
+ public String getVirtualKey() {
+ return virtualKey;
+ }
+ public AccessibilityNode setVirtualKey(String virtualKey) {
+ if (virtualKey == null || virtualKey.length() == 0) {
+ throw new IllegalArgumentException("Virtual accessibility nodes require a non-empty stable key");
+ }
+ this.virtualKey = virtualKey;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public String getIdentifier() {
+ return identifier;
+ }
+ public AccessibilityNode setIdentifier(String identifier) {
+ this.identifier = identifier;
+ return changed(AccessibilityManager.CHANGE_CONTENT);
+ }
+ public String getLabel() {
+ return label;
+ }
+ public AccessibilityNode setLabel(String label) {
+ this.label = label;
+ return changed(AccessibilityManager.CHANGE_CONTENT);
+ }
+ public String getHint() {
+ return hint;
+ }
+ public AccessibilityNode setHint(String hint) {
+ this.hint = hint;
+ return changed(AccessibilityManager.CHANGE_CONTENT);
+ }
+ public String getDescription() {
+ return description;
+ }
+ public AccessibilityNode setDescription(String description) {
+ this.description = description;
+ return changed(AccessibilityManager.CHANGE_CONTENT);
+ }
+ public String getValue() {
+ return value;
+ }
+ public AccessibilityNode setValue(String value) {
+ this.value = value;
+ return changed(AccessibilityManager.CHANGE_VALUE);
+ }
+ public String getValidationError() {
+ return validationError;
+ }
+ public AccessibilityNode setValidationError(String error) {
+ this.validationError = error;
+ this.invalid = error == null ? null : Boolean.TRUE;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public String getPaneTitle() {
+ return paneTitle;
+ }
+ public AccessibilityNode setPaneTitle(String paneTitle) {
+ this.paneTitle = paneTitle;
+ return changed(AccessibilityManager.CHANGE_PANE);
+ }
+ public String getRoleDescription() {
+ return roleDescription;
+ }
+ public AccessibilityNode setRoleDescription(String roleDescription) {
+ this.roleDescription = roleDescription;
+ return changed(AccessibilityManager.CHANGE_CONTENT);
+ }
+ public AccessibilityRole getRole() {
+ return role;
+ }
+ public AccessibilityNode setRole(AccessibilityRole role) {
+ this.role = role == null ? AccessibilityRole.NONE : role;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public AccessibilityCheckedState getChecked() {
+ return checked;
+ }
+ public AccessibilityNode setChecked(AccessibilityCheckedState checked) {
+ this.checked = checked == null ? AccessibilityCheckedState.UNSPECIFIED : checked;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public AccessibilityLiveRegion getLiveRegion() {
+ return liveRegion;
+ }
+ public AccessibilityNode setLiveRegion(AccessibilityLiveRegion liveRegion) {
+ this.liveRegion = liveRegion == null ? AccessibilityLiveRegion.OFF : liveRegion;
+ return changed(AccessibilityManager.CHANGE_LIVE_REGION);
+ }
+ public AccessibilityGrouping getGrouping() {
+ return grouping;
+ }
+ public AccessibilityNode setGrouping(AccessibilityGrouping grouping) {
+ this.grouping = grouping == null ? AccessibilityGrouping.AUTO : grouping;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public AccessibilityRange getRange() {
+ return range;
+ }
+ public AccessibilityNode setRange(AccessibilityRange range) {
+ this.range = range;
+ return changed(AccessibilityManager.CHANGE_VALUE);
+ }
+ public AccessibilityCollectionInfo getCollectionInfo() {
+ return collectionInfo;
+ }
+ public AccessibilityNode setCollectionInfo(AccessibilityCollectionInfo info) {
+ this.collectionInfo = info;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public AccessibilityCollectionItemInfo getCollectionItemInfo() {
+ return collectionItemInfo;
+ }
+ public AccessibilityNode setCollectionItemInfo(AccessibilityCollectionItemInfo info) {
+ this.collectionItemInfo = info;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public AccessibilityChildProvider getChildProvider() {
+ return childProvider;
+ }
+ public AccessibilityNode setChildProvider(AccessibilityChildProvider provider) {
+ this.childProvider = provider;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public Rectangle getBounds() {
+ return bounds == null ? null : new Rectangle(bounds);
+ }
+ public AccessibilityNode setBounds(Rectangle bounds) {
+ this.bounds = bounds == null ? null : new Rectangle(bounds);
+ return changed(AccessibilityManager.CHANGE_BOUNDS);
+ }
+ public Boolean getSelected() {
+ return selected;
+ }
+ public AccessibilityNode setSelected(Boolean selected) {
+ this.selected = selected;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getExpanded() {
+ return expanded;
+ }
+ public AccessibilityNode setExpanded(Boolean expanded) {
+ this.expanded = expanded;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getEnabled() {
+ return enabled;
+ }
+ public AccessibilityNode setEnabled(Boolean enabled) {
+ this.enabled = enabled;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getInvalid() {
+ return invalid;
+ }
+ public AccessibilityNode setInvalid(Boolean invalid) {
+ this.invalid = invalid;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getBusy() {
+ return busy;
+ }
+ public AccessibilityNode setBusy(Boolean busy) {
+ this.busy = busy;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getReadOnly() {
+ return readOnly;
+ }
+ public AccessibilityNode setReadOnly(Boolean readOnly) {
+ this.readOnly = readOnly;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getRequired() {
+ return required;
+ }
+ public AccessibilityNode setRequired(Boolean required) {
+ this.required = required;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getMultiline() {
+ return multiline;
+ }
+ public AccessibilityNode setMultiline(Boolean multiline) {
+ this.multiline = multiline;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getObscured() {
+ return obscured;
+ }
+ public AccessibilityNode setObscured(Boolean obscured) {
+ this.obscured = obscured;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getPressed() {
+ return pressed;
+ }
+ public AccessibilityNode setPressed(Boolean pressed) {
+ this.pressed = pressed;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public Boolean getCurrent() {
+ return current;
+ }
+ public AccessibilityNode setCurrent(Boolean current) {
+ this.current = current;
+ return changed(AccessibilityManager.CHANGE_STATE);
+ }
+ public boolean isModal() {
+ return modal;
+ }
+ public AccessibilityNode setModal(boolean modal) {
+ this.modal = modal;
+ return changed(AccessibilityManager.CHANGE_PANE);
+ }
+ public int getHeadingLevel() {
+ return headingLevel;
+ }
+ public AccessibilityNode setHeadingLevel(int level) {
+ this.headingLevel = level;
+ if (level > 0 && role == AccessibilityRole.NONE) {
+ role = AccessibilityRole.HEADING;
+ }
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public double getSortKey() {
+ return sortKey;
+ }
+ public AccessibilityNode setSortKey(double sortKey) {
+ this.sortKey = sortKey;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public Component getTraversalBefore() {
+ return traversalBefore;
+ }
+ public AccessibilityNode setTraversalBefore(Component component) {
+ this.traversalBefore = component;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+ public Component getTraversalAfter() {
+ return traversalAfter;
+ }
+ public AccessibilityNode setTraversalAfter(Component component) {
+ this.traversalAfter = component;
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+
+ public AccessibilityNode addAction(AccessibilityAction action) {
+ if (action == null) {
+ throw new NullPointerException("action");
+ }
+ for (int i = actions.size() - 1; i >= 0; i--) {
+ if (actions.get(i).getId().equals(action.getId())) {
+ actions.remove(i);
+ }
+ }
+ actions.add(action);
+ return changed(AccessibilityManager.CHANGE_ACTIONS);
+ }
+
+ public AccessibilityNode removeAction(String id) {
+ for (int i = actions.size() - 1; i >= 0; i--) {
+ if (actions.get(i).getId().equals(id)) {
+ actions.remove(i);
+ }
+ }
+ return changed(AccessibilityManager.CHANGE_ACTIONS);
+ }
+
+ public List getActions() {
+ return Collections.unmodifiableList(actions);
+ }
+
+ public AccessibilityNode addChild(AccessibilityNode child) {
+ if (child == null) {
+ throw new NullPointerException("child");
+ }
+ children.add(child);
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+
+ public AccessibilityNode clearChildren() {
+ children.clear();
+ return changed(AccessibilityManager.CHANGE_STRUCTURE);
+ }
+
+ public List getChildren() {
+ return Collections.unmodifiableList(children);
+ }
+
+ public boolean hasExplicitConfiguration() {
+ return role != AccessibilityRole.NONE || label != null || hint != null || description != null ||
+ value != null || validationError != null || paneTitle != null || roleDescription != null ||
+ checked != AccessibilityCheckedState.UNSPECIFIED || liveRegion != AccessibilityLiveRegion.OFF ||
+ grouping != AccessibilityGrouping.AUTO || range != null || collectionInfo != null ||
+ collectionItemInfo != null || childProvider != null || bounds != null || selected != null ||
+ expanded != null || enabled != null || invalid != null || busy != null || readOnly != null ||
+ required != null || multiline != null || obscured != null || pressed != null || current != null ||
+ modal || headingLevel > 0 || !Double.isNaN(sortKey) || traversalBefore != null ||
+ traversalAfter != null || !actions.isEmpty() || !children.isEmpty();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityNodeSnapshot.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityNodeSnapshot.java
new file mode 100644
index 00000000000..5cdbc9bb450
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityNodeSnapshot.java
@@ -0,0 +1,271 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.ui.Component;
+import com.codename1.ui.geom.Rectangle;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/// Immutable resolved node exported to platform accessibility adapters and test tooling.
+public final class AccessibilityNodeSnapshot {
+ private final long id;
+ private final long parentId;
+ private final Component component;
+ private final String virtualKey;
+ private final String identifier;
+ private final String label;
+ private final String hint;
+ private final String description;
+ private final String value;
+ private final String validationError;
+ private final String paneTitle;
+ private final String roleDescription;
+ private final AccessibilityRole role;
+ private final AccessibilityCheckedState checked;
+ private final AccessibilityLiveRegion liveRegion;
+ private final AccessibilityRange range;
+ private final AccessibilityCollectionInfo collectionInfo;
+ private final AccessibilityCollectionItemInfo collectionItemInfo;
+ private final Rectangle bounds;
+ private final Boolean selected;
+ private final Boolean expanded;
+ private final Boolean enabled;
+ private final Boolean invalid;
+ private final Boolean busy;
+ private final Boolean readOnly;
+ private final Boolean required;
+ private final Boolean multiline;
+ private final Boolean obscured;
+ private final Boolean pressed;
+ private final Boolean current;
+ private final boolean modal;
+ private final boolean focusable;
+ private final boolean focused;
+ private final int headingLevel;
+ private final List childIds;
+ private final List actions;
+
+ AccessibilityNodeSnapshot(Builder b) {
+ id = b.id;
+ parentId = b.parentId;
+ component = b.component;
+ virtualKey = b.virtualKey;
+ identifier = b.identifier;
+ label = b.label;
+ hint = b.hint;
+ description = b.description;
+ value = b.value;
+ validationError = b.validationError;
+ paneTitle = b.paneTitle;
+ roleDescription = b.roleDescription;
+ role = b.role;
+ checked = b.checked;
+ liveRegion = b.liveRegion;
+ range = b.range;
+ collectionInfo = b.collectionInfo;
+ collectionItemInfo = b.collectionItemInfo;
+ bounds = b.bounds == null ? new Rectangle() : new Rectangle(b.bounds);
+ selected = b.selected;
+ expanded = b.expanded;
+ enabled = b.enabled;
+ invalid = b.invalid;
+ busy = b.busy;
+ readOnly = b.readOnly;
+ required = b.required;
+ multiline = b.multiline;
+ obscured = b.obscured;
+ pressed = b.pressed;
+ current = b.current;
+ modal = b.modal;
+ focusable = b.focusable;
+ focused = b.focused;
+ headingLevel = b.headingLevel;
+ childIds = Collections.unmodifiableList(new ArrayList(b.childIds));
+ actions = Collections.unmodifiableList(new ArrayList(b.actions));
+ }
+
+ public long getId() {
+ return id;
+ }
+ public long getParentId() {
+ return parentId;
+ }
+ public Component getComponent() {
+ return component;
+ }
+ public String getVirtualKey() {
+ return virtualKey;
+ }
+ public String getIdentifier() {
+ return identifier;
+ }
+ public String getLabel() {
+ return label;
+ }
+ public String getHint() {
+ return hint;
+ }
+ public String getDescription() {
+ return description;
+ }
+ public String getValue() {
+ return value;
+ }
+ public String getValidationError() {
+ return validationError;
+ }
+ public String getPaneTitle() {
+ return paneTitle;
+ }
+ public String getRoleDescription() {
+ return roleDescription;
+ }
+ public AccessibilityRole getRole() {
+ return role;
+ }
+ public AccessibilityCheckedState getChecked() {
+ return checked;
+ }
+ public AccessibilityLiveRegion getLiveRegion() {
+ return liveRegion;
+ }
+ public AccessibilityRange getRange() {
+ return range;
+ }
+ public AccessibilityCollectionInfo getCollectionInfo() {
+ return collectionInfo;
+ }
+ public AccessibilityCollectionItemInfo getCollectionItemInfo() {
+ return collectionItemInfo;
+ }
+ public Rectangle getBounds() {
+ return new Rectangle(bounds);
+ }
+ public Boolean getSelected() {
+ return selected;
+ }
+ public Boolean getExpanded() {
+ return expanded;
+ }
+ public Boolean getEnabled() {
+ return enabled;
+ }
+ public Boolean getInvalid() {
+ return invalid;
+ }
+ public Boolean getBusy() {
+ return busy;
+ }
+ public Boolean getReadOnly() {
+ return readOnly;
+ }
+ public Boolean getRequired() {
+ return required;
+ }
+ public Boolean getMultiline() {
+ return multiline;
+ }
+ public Boolean getObscured() {
+ return obscured;
+ }
+ public Boolean getPressed() {
+ return pressed;
+ }
+ public Boolean getCurrent() {
+ return current;
+ }
+ public boolean isModal() {
+ return modal;
+ }
+ public boolean isFocusable() {
+ return focusable;
+ }
+ public boolean isFocused() {
+ return focused;
+ }
+ public int getHeadingLevel() {
+ return headingLevel;
+ }
+ public List getChildIds() {
+ return childIds;
+ }
+ public List getActions() {
+ return actions;
+ }
+
+ public AccessibilityAction getAction(String id) {
+ if (id == null) {
+ return null;
+ }
+ for (AccessibilityAction action : actions) {
+ if (id.equals(action.getId())) {
+ return action;
+ }
+ }
+ return null;
+ }
+
+ static final class Builder {
+ long id;
+ long parentId = -1;
+ Component component;
+ String virtualKey;
+ String identifier;
+ String label;
+ String hint;
+ String description;
+ String value;
+ String validationError;
+ String paneTitle;
+ String roleDescription;
+ AccessibilityRole role = AccessibilityRole.GENERIC;
+ AccessibilityCheckedState checked = AccessibilityCheckedState.UNSPECIFIED;
+ AccessibilityLiveRegion liveRegion = AccessibilityLiveRegion.OFF;
+ AccessibilityRange range;
+ AccessibilityCollectionInfo collectionInfo;
+ AccessibilityCollectionItemInfo collectionItemInfo;
+ Rectangle bounds;
+ Boolean selected;
+ Boolean expanded;
+ Boolean enabled;
+ Boolean invalid;
+ Boolean busy;
+ Boolean readOnly;
+ Boolean required;
+ Boolean multiline;
+ Boolean obscured;
+ Boolean pressed;
+ Boolean current;
+ boolean modal;
+ boolean focusable;
+ boolean focused;
+ int headingLevel = -1;
+ double sortKey = Double.NaN;
+ Component traversalBefore;
+ Component traversalAfter;
+ List childIds = new ArrayList();
+ List actions = new ArrayList();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityRange.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityRange.java
new file mode 100644
index 00000000000..2369bb71443
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityRange.java
@@ -0,0 +1,74 @@
+/*
+ * 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.accessibility;
+
+/// Numeric value metadata for sliders, progress indicators, spin buttons, and
+/// other adjustable controls.
+public final class AccessibilityRange {
+ private final double minimum;
+ private final double maximum;
+ private final double current;
+ private final double step;
+ private final String text;
+
+ public AccessibilityRange(double minimum, double maximum, double current) {
+ this(minimum, maximum, current, 0, null);
+ }
+
+ public AccessibilityRange(double minimum, double maximum, double current, double step) {
+ this(minimum, maximum, current, step, null);
+ }
+
+ public AccessibilityRange(double minimum, double maximum, double current, double step, String text) {
+ this.minimum = minimum;
+ this.maximum = maximum;
+ this.current = current;
+ this.step = step;
+ this.text = text;
+ }
+
+ public double getMinimum() {
+ return minimum;
+ }
+
+ public double getMaximum() {
+ return maximum;
+ }
+
+ public double getCurrent() {
+ return current;
+ }
+
+ public double getStep() {
+ return step;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public boolean isValid() {
+ return !Double.isNaN(minimum) && !Double.isNaN(maximum) && !Double.isNaN(current)
+ && minimum <= maximum && current >= minimum && current <= maximum && step >= 0;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityRole.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityRole.java
new file mode 100644
index 00000000000..8349a6388e2
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityRole.java
@@ -0,0 +1,67 @@
+/*
+ * 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.accessibility;
+
+/// The semantic role of a lightweight component or virtual accessibility node.
+///
+/// Roles are intentionally portable. Platform ports map them to native control
+/// types, traits, patterns, or ARIA roles and fall back to {@link #GENERIC} when
+/// a platform has no exact equivalent.
+public enum AccessibilityRole {
+ NONE,
+ GENERIC,
+ BUTTON,
+ TOGGLE_BUTTON,
+ CHECKBOX,
+ RADIO_BUTTON,
+ SWITCH,
+ HEADING,
+ LINK,
+ IMAGE,
+ STATIC_TEXT,
+ TEXT_FIELD,
+ SEARCH_FIELD,
+ SLIDER,
+ PROGRESS_BAR,
+ LIST,
+ LIST_ITEM,
+ GRID,
+ ROW,
+ CELL,
+ COLUMN_HEADER,
+ ROW_HEADER,
+ TAB_LIST,
+ TAB,
+ TAB_PANEL,
+ DIALOG,
+ ALERT,
+ MENU,
+ MENU_ITEM,
+ TOOLBAR,
+ SCROLL_BAR,
+ SPIN_BUTTON,
+ COMBO_BOX,
+ TREE,
+ TREE_ITEM,
+ SEPARATOR
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityTreeSnapshot.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityTreeSnapshot.java
new file mode 100644
index 00000000000..a55739ca874
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityTreeSnapshot.java
@@ -0,0 +1,271 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.ui.geom.Rectangle;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/// Immutable accessibility tree for the current form.
+public final class AccessibilityTreeSnapshot {
+ private final long generation;
+ private final List rootIds;
+ private final Map nodes;
+
+ AccessibilityTreeSnapshot(long generation, List rootIds, Map nodes) {
+ this.generation = generation;
+ this.rootIds = Collections.unmodifiableList(new ArrayList(rootIds));
+ this.nodes = Collections.unmodifiableMap(new LinkedHashMap(nodes));
+ }
+
+ public long getGeneration() {
+ return generation;
+ }
+ public List getRootIds() {
+ return rootIds;
+ }
+ public Map getNodes() {
+ return nodes;
+ }
+ public AccessibilityNodeSnapshot getNode(long id) {
+ return nodes.get(Long.valueOf(id));
+ }
+
+ /// Finds a node by its application-supplied stable identifier.
+ ///
+ /// #### Parameters
+ ///
+ /// - `identifier`: identifier assigned with `AccessibilityNode#setIdentifier(String)`
+ ///
+ /// #### Returns
+ ///
+ /// the matching node, or null when no node has that identifier.
+ public AccessibilityNodeSnapshot getNodeByIdentifier(String identifier) {
+ if (identifier == null) {
+ return null;
+ }
+ for (AccessibilityNodeSnapshot node : nodes.values()) {
+ if (identifier.equals(node.getIdentifier())) {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ /// Returns the deepest semantic node containing the screen coordinate.
+ public AccessibilityNodeSnapshot getNodeAt(int x, int y) {
+ AccessibilityNodeSnapshot best = null;
+ for (AccessibilityNodeSnapshot node : nodes.values()) {
+ Rectangle r = node.getBounds();
+ if (r.getWidth() > 0 && r.getHeight() > 0 && r.contains(x, y)) {
+ if (best == null || isDescendant(node, best)) {
+ best = node;
+ }
+ }
+ }
+ return best;
+ }
+
+ private boolean isDescendant(AccessibilityNodeSnapshot possibleChild, AccessibilityNodeSnapshot possibleParent) {
+ long parent = possibleChild.getParentId();
+ while (parent >= 0) {
+ if (parent == possibleParent.getId()) {
+ return true;
+ }
+ AccessibilityNodeSnapshot node = getNode(parent);
+ if (node == null) {
+ return false;
+ }
+ parent = node.getParentId();
+ }
+ return false;
+ }
+
+ public String toJson() {
+ StringBuilder out = new StringBuilder();
+ out.append("{\"generation\":").append(generation).append(",\"roots\":[");
+ for (int i = 0; i < rootIds.size(); i++) {
+ if (i > 0) {
+ out.append(',');
+ }
+ out.append(rootIds.get(i));
+ }
+ out.append("],\"nodes\":[");
+ boolean firstNode = true;
+ for (AccessibilityNodeSnapshot node : nodes.values()) {
+ if (!firstNode) {
+ out.append(',');
+ }
+ firstNode = false;
+ out.append('{');
+ out.append("\"id\":").append(node.getId());
+ out.append(",\"parent\":").append(node.getParentId());
+ out.append(",\"role\":");
+ appendJson(out, node.getRole().name());
+ out.append(",\"label\":");
+ appendJson(out, node.getLabel());
+ out.append(",\"value\":");
+ appendJson(out, node.getValue());
+ out.append(",\"hint\":");
+ appendJson(out, node.getHint());
+ out.append(",\"description\":");
+ appendJson(out, node.getDescription());
+ out.append(",\"error\":");
+ appendJson(out, node.getValidationError());
+ out.append(",\"paneTitle\":");
+ appendJson(out, node.getPaneTitle());
+ out.append(",\"identifier\":");
+ appendJson(out, node.getIdentifier());
+ out.append(",\"roleDescription\":");
+ appendJson(out, node.getRoleDescription());
+ out.append(",\"checked\":");
+ appendJson(out, node.getChecked().name());
+ out.append(",\"liveRegion\":");
+ appendJson(out, node.getLiveRegion().name());
+ out.append(",\"selected\":");
+ appendBoolean(out, node.getSelected());
+ out.append(",\"expanded\":");
+ appendBoolean(out, node.getExpanded());
+ out.append(",\"enabled\":");
+ appendBoolean(out, node.getEnabled());
+ out.append(",\"invalid\":");
+ appendBoolean(out, node.getInvalid());
+ out.append(",\"busy\":");
+ appendBoolean(out, node.getBusy());
+ out.append(",\"readOnly\":");
+ appendBoolean(out, node.getReadOnly());
+ out.append(",\"required\":");
+ appendBoolean(out, node.getRequired());
+ out.append(",\"multiline\":");
+ appendBoolean(out, node.getMultiline());
+ out.append(",\"obscured\":");
+ appendBoolean(out, node.getObscured());
+ out.append(",\"pressed\":");
+ appendBoolean(out, node.getPressed());
+ out.append(",\"current\":");
+ appendBoolean(out, node.getCurrent());
+ out.append(",\"modal\":").append(node.isModal());
+ out.append(",\"focusable\":").append(node.isFocusable());
+ out.append(",\"focused\":").append(node.isFocused());
+ out.append(",\"headingLevel\":").append(node.getHeadingLevel());
+ if (node.getRange() != null) {
+ AccessibilityRange range = node.getRange();
+ out.append(",\"range\":{");
+ out.append("\"min\":").append(range.getMinimum());
+ out.append(",\"max\":").append(range.getMaximum());
+ out.append(",\"current\":").append(range.getCurrent());
+ out.append(",\"step\":").append(range.getStep());
+ out.append(",\"text\":");
+ appendJson(out, range.getText());
+ out.append('}');
+ }
+ if (node.getCollectionInfo() != null) {
+ AccessibilityCollectionInfo collection = node.getCollectionInfo();
+ out.append(",\"collection\":{");
+ out.append("\"rows\":").append(collection.getRowCount());
+ out.append(",\"columns\":").append(collection.getColumnCount());
+ out.append(",\"hierarchical\":").append(collection.isHierarchical());
+ out.append(",\"selectionMode\":").append(collection.getSelectionMode()).append('}');
+ }
+ if (node.getCollectionItemInfo() != null) {
+ AccessibilityCollectionItemInfo item = node.getCollectionItemInfo();
+ out.append(",\"collectionItem\":{");
+ out.append("\"row\":").append(item.getRowIndex());
+ out.append(",\"rowSpan\":").append(item.getRowSpan());
+ out.append(",\"column\":").append(item.getColumnIndex());
+ out.append(",\"columnSpan\":").append(item.getColumnSpan());
+ out.append(",\"position\":").append(item.getPositionInSet());
+ out.append(",\"setSize\":").append(item.getSetSize());
+ out.append(",\"level\":").append(item.getLevel());
+ out.append(",\"heading\":").append(item.isHeading()).append('}');
+ }
+ Rectangle bounds = node.getBounds();
+ out.append(",\"bounds\":[")
+ .append(bounds.getX())
+ .append(',')
+ .append(bounds.getY())
+ .append(',')
+ .append(bounds.getWidth())
+ .append(',')
+ .append(bounds.getHeight())
+ .append(']');
+ out.append(",\"children\":[");
+ for (int i = 0; i < node.getChildIds().size(); i++) {
+ if (i > 0) {
+ out.append(',');
+ }
+ out.append(node.getChildIds().get(i));
+ }
+ out.append("],\"actions\":[");
+ for (int i = 0; i < node.getActions().size(); i++) {
+ if (i > 0) {
+ out.append(',');
+ }
+ AccessibilityAction action = node.getActions().get(i);
+ out.append('{').append("\"id\":");
+ appendJson(out, action.getId());
+ out.append(",\"label\":");
+ appendJson(out, action.getLabel());
+ out.append(",\"enabled\":").append(action.isEnabled()).append('}');
+ }
+ out.append("]}");
+ }
+ out.append("]}");
+ return out.toString();
+ }
+
+ private static void appendJson(StringBuilder out, String value) {
+ if (value == null) {
+ out.append("null");
+ return;
+ }
+ out.append('"');
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c == '"' || c == '\\') {
+ out.append('\\');
+ }
+ if (c == '\n') {
+ out.append("\\n");
+ } else if (c == '\r') {
+ out.append("\\r");
+ } else if (c == '\t') {
+ out.append("\\t");
+ } else {
+ out.append(c);
+ }
+ }
+ out.append('"');
+ }
+
+ private static void appendBoolean(StringBuilder out, Boolean value) {
+ if (value == null) {
+ out.append("null");
+ } else {
+ out.append(value.booleanValue());
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ui/accessibility/package-info.java b/CodenameOne/src/com/codename1/ui/accessibility/package-info.java
new file mode 100644
index 00000000000..7857a86a70c
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ui/accessibility/package-info.java
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+/// Defines the portable accessibility semantics tree for Codename One's
+/// lightweight user-interface components. Applications use this package to
+/// describe roles, states, values, ranges, actions, traversal, grouping,
+/// live regions, panes, collections, and virtual descendants. Platform ports
+/// translate the resulting immutable tree to their native accessibility APIs.
+///
+/// The entry point for configuring a component is
+/// {@link com.codename1.ui.Component#getSemantics()}. Tests and development
+/// tools can inspect a resolved tree with
+/// {@link com.codename1.ui.accessibility.AccessibilityInspector} and validate
+/// it with {@link com.codename1.ui.accessibility.AccessibilityAssertions}.
+package com.codename1.ui.accessibility;
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidAccessibilityProvider.java b/Ports/Android/src/com/codename1/impl/android/AndroidAccessibilityProvider.java
new file mode 100644
index 00000000000..f8e704f54e2
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidAccessibilityProvider.java
@@ -0,0 +1,579 @@
+/*
+ * 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.impl.android;
+
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewParent;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeProvider;
+import com.codename1.ui.accessibility.AccessibilityAction;
+import com.codename1.ui.accessibility.AccessibilityCheckedState;
+import com.codename1.ui.accessibility.AccessibilityCollectionInfo;
+import com.codename1.ui.accessibility.AccessibilityCollectionItemInfo;
+import com.codename1.ui.accessibility.AccessibilityLiveRegion;
+import com.codename1.ui.accessibility.AccessibilityManager;
+import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
+import com.codename1.ui.accessibility.AccessibilityRange;
+import com.codename1.ui.accessibility.AccessibilityRole;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Android virtual-view adapter for Codename One's lightweight semantic tree. */
+final class AndroidAccessibilityProvider extends AccessibilityNodeProvider {
+ private static final int CUSTOM_ACTION_BASE = 0x01000000;
+ private static final String ROLE_DESCRIPTION_KEY = "AccessibilityNodeInfo.roleDescription";
+ private final View host;
+ private final AndroidImplementation implementation;
+ private final android.view.accessibility.AccessibilityManager nativeAccessibilityManager;
+ private final android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener stateListener;
+ private final Object touchListener;
+ private final Map virtualIds = new HashMap();
+ private final Map semanticIds = new HashMap();
+ private final Map customActionIds = new HashMap();
+ private final Map customActionKeys = new HashMap();
+ private long mappedGeneration = Long.MIN_VALUE;
+ private int nextVirtualId = 1;
+ private int nextCustomActionId = CUSTOM_ACTION_BASE;
+ private int accessibilityFocusedId = Integer.MIN_VALUE;
+
+ AndroidAccessibilityProvider(View host, AndroidImplementation implementation) {
+ this.host = host;
+ this.implementation = implementation;
+ nativeAccessibilityManager = (android.view.accessibility.AccessibilityManager) host.getContext()
+ .getSystemService(android.content.Context.ACCESSIBILITY_SERVICE);
+ stateListener = new android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener() {
+ @Override
+ public void onAccessibilityStateChanged(boolean enabled) {
+ accessibilityConsumerChanged();
+ }
+ };
+ touchListener = Build.VERSION.SDK_INT >= 19 && nativeAccessibilityManager != null
+ ? Api19TouchExploration.register(nativeAccessibilityManager, this) : null;
+ if (nativeAccessibilityManager != null) {
+ nativeAccessibilityManager.addAccessibilityStateChangeListener(stateListener);
+ }
+ host.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ accessibilityConsumerChanged();
+ }
+
+ void dispose() {
+ implementation.setAccessibilityTreeUpdateRequired(false);
+ if (nativeAccessibilityManager != null) {
+ nativeAccessibilityManager.removeAccessibilityStateChangeListener(stateListener);
+ if (Build.VERSION.SDK_INT >= 19 && touchListener != null) {
+ Api19TouchExploration.unregister(nativeAccessibilityManager, touchListener);
+ }
+ }
+ virtualIds.clear();
+ semanticIds.clear();
+ customActionIds.clear();
+ customActionKeys.clear();
+ }
+
+ private void accessibilityConsumerChanged() {
+ boolean active = implementation.isScreenReaderEnabled();
+ implementation.setAccessibilityTreeUpdateRequired(active);
+ if (active) {
+ AccessibilityManager.getInstance().invalidateAll();
+ }
+ }
+
+ private static final class Api19TouchExploration {
+ private Api19TouchExploration() {
+ }
+
+ static Object register(android.view.accessibility.AccessibilityManager manager,
+ final AndroidAccessibilityProvider provider) {
+ android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener listener =
+ new android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener() {
+ @Override
+ public void onTouchExplorationStateChanged(boolean enabled) {
+ provider.accessibilityConsumerChanged();
+ }
+ };
+ manager.addTouchExplorationStateChangeListener(listener);
+ return listener;
+ }
+
+ static void unregister(android.view.accessibility.AccessibilityManager manager, Object value) {
+ manager.removeTouchExplorationStateChangeListener(
+ (android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener)value);
+ }
+ }
+
+ @Override
+ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
+ AccessibilityTreeSnapshot tree = currentTree();
+ if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) {
+ return createHostNode(tree);
+ }
+ AccessibilityNodeSnapshot node = nodeForVirtualId(tree, virtualViewId);
+ return node == null ? null : createVirtualNode(tree, node, virtualViewId);
+ }
+
+ private AccessibilityNodeInfo createHostNode(AccessibilityTreeSnapshot tree) {
+ AccessibilityNodeInfo info = AccessibilityNodeInfo.obtain(host);
+ host.onInitializeAccessibilityNodeInfo(info);
+ info.setClassName("com.codename1.ui.Form");
+ info.setPackageName(host.getContext().getPackageName());
+ info.setScrollable(false);
+ for (Long rootId : tree.getRootIds()) {
+ info.addChild(host, virtualIdFor(rootId.longValue()));
+ }
+ return info;
+ }
+
+ private AccessibilityNodeInfo createVirtualNode(AccessibilityTreeSnapshot tree,
+ AccessibilityNodeSnapshot node, int virtualViewId) {
+ AccessibilityNodeInfo info = AccessibilityNodeInfo.obtain();
+ info.setPackageName(host.getContext().getPackageName());
+ info.setClassName(className(node.getRole()));
+ info.setSource(host, virtualViewId);
+ if (node.getParentId() < 0) info.setParent(host);
+ else info.setParent(host, virtualIdFor(node.getParentId()));
+ for (Long childId : node.getChildIds()) info.addChild(host, virtualIdFor(childId.longValue()));
+
+ String label = node.getLabel();
+ if (usesText(node.getRole())) info.setText(label);
+ else info.setContentDescription(label);
+ if (Build.VERSION.SDK_INT >= 26 && node.getHint() != null) info.setHintText(node.getHint());
+ if (Build.VERSION.SDK_INT >= 19 && node.getRoleDescription() != null) {
+ info.getExtras().putCharSequence(ROLE_DESCRIPTION_KEY, node.getRoleDescription());
+ }
+ if (Build.VERSION.SDK_INT >= 18 && node.getIdentifier() != null) {
+ info.setViewIdResourceName(host.getContext().getPackageName() + ":id/" + node.getIdentifier());
+ }
+
+ boolean enabled = node.getEnabled() == null || node.getEnabled().booleanValue();
+ info.setEnabled(enabled);
+ info.setVisibleToUser(isVisible(node));
+ info.setFocusable(node.isFocusable());
+ info.setFocused(node.isFocused());
+ info.setAccessibilityFocused(virtualViewId == accessibilityFocusedId);
+ info.setSelected(Boolean.TRUE.equals(node.getSelected()));
+ info.setClickable(hasAction(node, AccessibilityAction.ACTIVATE));
+ info.setLongClickable(hasAction(node, AccessibilityAction.LONG_PRESS));
+ info.setScrollable(hasAction(node, AccessibilityAction.SCROLL_FORWARD)
+ || hasAction(node, AccessibilityAction.SCROLL_BACKWARD));
+
+ AccessibilityCheckedState checked = node.getChecked();
+ if (checked != AccessibilityCheckedState.UNSPECIFIED) {
+ info.setCheckable(true);
+ info.setChecked(checked == AccessibilityCheckedState.CHECKED || checked == AccessibilityCheckedState.MIXED);
+ if (checked == AccessibilityCheckedState.MIXED && Build.VERSION.SDK_INT >= 19) {
+ info.getExtras().putCharSequence("com.codename1.accessibility.checkedState", "mixed");
+ }
+ }
+ if (Build.VERSION.SDK_INT >= 19) {
+ info.setContentInvalid(Boolean.TRUE.equals(node.getInvalid()));
+ if (node.getCollectionInfo() != null) info.setCollectionInfo(collection(node.getCollectionInfo()));
+ if (node.getCollectionItemInfo() != null) info.setCollectionItemInfo(collectionItem(node.getCollectionItemInfo(), node));
+ if (node.getRange() != null) info.setRangeInfo(range(node.getRange()));
+ if (node.getExpanded() != null) {
+ info.getExtras().putBoolean("com.codename1.accessibility.expanded", node.getExpanded().booleanValue());
+ }
+ }
+ if (Build.VERSION.SDK_INT >= 21 && node.getValidationError() != null) info.setError(node.getValidationError());
+ if (Build.VERSION.SDK_INT >= 28) {
+ setApi28Semantics(info,
+ node.getHeadingLevel() > 0
+ || node.getRole() == AccessibilityRole.HEADING
+ || node.getCollectionItemInfo() != null && node.getCollectionItemInfo().isHeading(),
+ node.isFocusable() || label != null, node.getPaneTitle());
+ }
+ if (Build.VERSION.SDK_INT >= 19) {
+ info.setLiveRegion(node.getLiveRegion() == AccessibilityLiveRegion.ASSERTIVE
+ ? View.ACCESSIBILITY_LIVE_REGION_ASSERTIVE
+ : node.getLiveRegion() == AccessibilityLiveRegion.POLITE
+ ? View.ACCESSIBILITY_LIVE_REGION_POLITE : View.ACCESSIBILITY_LIVE_REGION_NONE);
+ }
+
+ addActions(info, node);
+ setBounds(info, node);
+ return info;
+ }
+
+ /**
+ * Invokes API 28 node properties without requiring the Android port's
+ * compile-time SDK stub to expose them. Release builds may compile the
+ * port against an older android.jar even though these calls run only on
+ * Android 9 and newer.
+ */
+ private static void setApi28Semantics(AccessibilityNodeInfo info, boolean heading,
+ boolean screenReaderFocusable, String paneTitle) {
+ try {
+ Class type = AccessibilityNodeInfo.class;
+ type.getMethod("setHeading", new Class[]{Boolean.TYPE})
+ .invoke(info, new Object[]{Boolean.valueOf(heading)});
+ type.getMethod("setScreenReaderFocusable", new Class[]{Boolean.TYPE})
+ .invoke(info, new Object[]{Boolean.valueOf(screenReaderFocusable)});
+ if (paneTitle != null) {
+ type.getMethod("setPaneTitle", new Class[]{CharSequence.class})
+ .invoke(info, new Object[]{paneTitle});
+ }
+ } catch (Throwable ignored) {
+ // The runtime API is authoritative; gracefully omit these optional
+ // properties on vendor builds that don't expose the API 28 methods.
+ }
+ }
+
+ private void addActions(AccessibilityNodeInfo info, AccessibilityNodeSnapshot node) {
+ if (node.isFocusable()) info.addAction(AccessibilityNodeInfo.ACTION_FOCUS);
+ if (hasAction(node, AccessibilityAction.ACTIVATE)) info.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+ if (hasAction(node, AccessibilityAction.LONG_PRESS)) info.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK);
+ if (hasAction(node, AccessibilityAction.INCREMENT)) info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
+ if (hasAction(node, AccessibilityAction.DECREMENT)) info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
+ if (hasAction(node, AccessibilityAction.SCROLL_FORWARD)) info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
+ if (hasAction(node, AccessibilityAction.SCROLL_BACKWARD)) info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
+ info.addAction(accessibilityFocusedId == virtualIdFor(node.getId())
+ ? AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS
+ : AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
+ if (Build.VERSION.SDK_INT >= 21) {
+ for (AccessibilityAction action : node.getActions()) {
+ if (isStandard(action.getId())) continue;
+ info.addAction(new AccessibilityNodeInfo.AccessibilityAction(
+ customActionId(node.getId(), action.getId()),
+ action.getLabel() == null ? action.getId() : action.getLabel()));
+ }
+ if (hasAction(node, AccessibilityAction.SET_TEXT)) {
+ info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_TEXT);
+ }
+ }
+ }
+
+ @Override
+ public boolean performAction(int virtualViewId, int action, Bundle arguments) {
+ if (virtualViewId == AccessibilityNodeProvider.HOST_VIEW_ID) {
+ return host.performAccessibilityAction(action, arguments);
+ }
+ AccessibilityTreeSnapshot tree = currentTree();
+ AccessibilityNodeSnapshot node = nodeForVirtualId(tree, virtualViewId);
+ if (node == null) return false;
+ if (action == AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) {
+ if (accessibilityFocusedId == virtualViewId) return false;
+ int previous = accessibilityFocusedId;
+ accessibilityFocusedId = virtualViewId;
+ if (previous != Integer.MIN_VALUE) sendEvent(previous, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
+ sendEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
+ return true;
+ }
+ if (action == AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS) {
+ if (accessibilityFocusedId != virtualViewId) return false;
+ accessibilityFocusedId = Integer.MIN_VALUE;
+ sendEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
+ return true;
+ }
+ String actionId = null;
+ Object argument = null;
+ if (action == AccessibilityNodeInfo.ACTION_CLICK) actionId = AccessibilityAction.ACTIVATE;
+ else if (action == AccessibilityNodeInfo.ACTION_LONG_CLICK) actionId = AccessibilityAction.LONG_PRESS;
+ else if (action == AccessibilityNodeInfo.ACTION_FOCUS) actionId = AccessibilityAction.FOCUS;
+ else if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
+ actionId = hasAction(node, AccessibilityAction.INCREMENT)
+ ? AccessibilityAction.INCREMENT : AccessibilityAction.SCROLL_FORWARD;
+ } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
+ actionId = hasAction(node, AccessibilityAction.DECREMENT)
+ ? AccessibilityAction.DECREMENT : AccessibilityAction.SCROLL_BACKWARD;
+ } else if (Build.VERSION.SDK_INT >= 21 && action == AccessibilityNodeInfo.ACTION_SET_TEXT) {
+ actionId = AccessibilityAction.SET_TEXT;
+ argument = arguments == null ? null : arguments.getCharSequence(
+ AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
+ if (argument != null) argument = argument.toString();
+ } else {
+ for (AccessibilityAction candidate : node.getActions()) {
+ if (!isStandard(candidate.getId())
+ && customActionId(node.getId(), candidate.getId()) == action) {
+ actionId = candidate.getId();
+ break;
+ }
+ }
+ }
+ return actionId != null && implementation.performAccessibilityAction(node.getId(), actionId, argument);
+ }
+
+ @Override
+ public List findAccessibilityNodeInfosByText(String searched, int virtualViewId) {
+ if (searched == null) return Collections.emptyList();
+ String lower = searched.toLowerCase();
+ List result = new ArrayList();
+ AccessibilityTreeSnapshot tree = currentTree();
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ if (contains(node.getLabel(), lower) || contains(node.getValue(), lower)
+ || contains(node.getDescription(), lower)) {
+ result.add(createVirtualNode(tree, node, virtualIdFor(node.getId())));
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public AccessibilityNodeInfo findFocus(int focus) {
+ AccessibilityTreeSnapshot tree = currentTree();
+ if (focus == AccessibilityNodeInfo.FOCUS_ACCESSIBILITY && accessibilityFocusedId != Integer.MIN_VALUE) {
+ AccessibilityNodeSnapshot node = nodeForVirtualId(tree, accessibilityFocusedId);
+ return node == null ? null : createVirtualNode(tree, node, accessibilityFocusedId);
+ }
+ if (focus == AccessibilityNodeInfo.FOCUS_INPUT) {
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ if (node.isFocused()) return createVirtualNode(tree, node, virtualIdFor(node.getId()));
+ }
+ }
+ return null;
+ }
+
+ void invalidate(int changes) {
+ if ((changes & AccessibilityManager.CHANGE_STRUCTURE) != 0
+ || (changes & AccessibilityManager.CHANGE_PANE) != 0) {
+ sendHostEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
+ } else if ((changes & AccessibilityManager.CHANGE_FOCUS) != 0) {
+ sendHostEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
+ } else {
+ sendHostEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
+ }
+ }
+
+ private void sendHostEvent(int type) {
+ AccessibilityEvent event = AccessibilityEvent.obtain(type);
+ event.setPackageName(host.getContext().getPackageName());
+ event.setClassName("com.codename1.ui.Form");
+ event.setSource(host);
+ send(event);
+ }
+
+ private void sendEvent(int virtualId, int type) {
+ AccessibilityEvent event = AccessibilityEvent.obtain(type);
+ event.setPackageName(host.getContext().getPackageName());
+ event.setClassName("com.codename1.ui.Component");
+ event.setSource(host, virtualId);
+ send(event);
+ }
+
+ private void send(AccessibilityEvent event) {
+ if (nativeAccessibilityManager == null || !nativeAccessibilityManager.isEnabled()) {
+ event.recycle();
+ return;
+ }
+ ViewParent parent = host.getParent();
+ if (parent != null) {
+ parent.requestSendAccessibilityEvent(host, event);
+ } else {
+ event.recycle();
+ }
+ }
+
+ private void setBounds(AccessibilityNodeInfo info, AccessibilityNodeSnapshot node) {
+ com.codename1.ui.geom.Rectangle b = node.getBounds();
+ Rect parent = new Rect(b.getX(), b.getY(), b.getX() + b.getWidth(), b.getY() + b.getHeight());
+ info.setBoundsInParent(parent);
+ int[] location = new int[2];
+ host.getLocationOnScreen(location);
+ Rect screen = new Rect(parent);
+ screen.offset(location[0], location[1]);
+ info.setBoundsInScreen(screen);
+ }
+
+ private boolean isVisible(AccessibilityNodeSnapshot node) {
+ com.codename1.ui.geom.Rectangle b = node.getBounds();
+ return host.isShown() && b.getWidth() > 0 && b.getHeight() > 0;
+ }
+
+ private AccessibilityNodeInfo.CollectionInfo collection(AccessibilityCollectionInfo info) {
+ int mode = info.getSelectionMode() == AccessibilityCollectionInfo.SELECTION_MULTIPLE
+ ? AccessibilityNodeInfo.CollectionInfo.SELECTION_MODE_MULTIPLE
+ : info.getSelectionMode() == AccessibilityCollectionInfo.SELECTION_SINGLE
+ ? AccessibilityNodeInfo.CollectionInfo.SELECTION_MODE_SINGLE
+ : AccessibilityNodeInfo.CollectionInfo.SELECTION_MODE_NONE;
+ if (Build.VERSION.SDK_INT >= 21) {
+ return AccessibilityNodeInfo.CollectionInfo.obtain(info.getRowCount(), info.getColumnCount(),
+ info.isHierarchical(), mode);
+ }
+ return AccessibilityNodeInfo.CollectionInfo.obtain(info.getRowCount(), info.getColumnCount(),
+ info.isHierarchical());
+ }
+
+ private AccessibilityNodeInfo.CollectionItemInfo collectionItem(AccessibilityCollectionItemInfo item,
+ AccessibilityNodeSnapshot node) {
+ if (Build.VERSION.SDK_INT >= 21) {
+ return AccessibilityNodeInfo.CollectionItemInfo.obtain(item.getRowIndex(), Math.max(1, item.getRowSpan()),
+ item.getColumnIndex(), Math.max(1, item.getColumnSpan()), item.isHeading(),
+ Boolean.TRUE.equals(node.getSelected()));
+ }
+ return AccessibilityNodeInfo.CollectionItemInfo.obtain(item.getRowIndex(), Math.max(1, item.getRowSpan()),
+ item.getColumnIndex(), Math.max(1, item.getColumnSpan()), item.isHeading());
+ }
+
+ private AccessibilityNodeInfo.RangeInfo range(AccessibilityRange range) {
+ return AccessibilityNodeInfo.RangeInfo.obtain(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT,
+ (float)range.getMinimum(), (float)range.getMaximum(), (float)range.getCurrent());
+ }
+
+ private String className(AccessibilityRole role) {
+ switch (role) {
+ case BUTTON: return "android.widget.Button";
+ case TOGGLE_BUTTON: return "android.widget.ToggleButton";
+ case CHECKBOX: return "android.widget.CheckBox";
+ case RADIO_BUTTON: return "android.widget.RadioButton";
+ case SWITCH: return "android.widget.Switch";
+ case TEXT_FIELD:
+ case SEARCH_FIELD: return "android.widget.EditText";
+ case SLIDER: return "android.widget.SeekBar";
+ case PROGRESS_BAR: return "android.widget.ProgressBar";
+ case LIST: return "android.widget.ListView";
+ case GRID: return "android.widget.GridView";
+ case IMAGE: return "android.widget.ImageView";
+ case TAB: return "android.app.ActionBar$Tab";
+ case DIALOG:
+ case ALERT: return "android.app.Dialog";
+ default: return "android.view.View";
+ }
+ }
+
+ private boolean usesText(AccessibilityRole role) {
+ return role == AccessibilityRole.STATIC_TEXT || role == AccessibilityRole.TEXT_FIELD
+ || role == AccessibilityRole.SEARCH_FIELD || role == AccessibilityRole.HEADING;
+ }
+
+ private boolean contains(String value, String searchedLower) {
+ return value != null && value.toLowerCase().contains(searchedLower);
+ }
+
+ private boolean hasAction(AccessibilityNodeSnapshot node, String id) {
+ return node.getAction(id) != null;
+ }
+
+ private boolean isStandard(String id) {
+ return AccessibilityAction.ACTIVATE.equals(id) || AccessibilityAction.LONG_PRESS.equals(id)
+ || AccessibilityAction.INCREMENT.equals(id) || AccessibilityAction.DECREMENT.equals(id)
+ || AccessibilityAction.FOCUS.equals(id) || AccessibilityAction.SET_TEXT.equals(id)
+ || AccessibilityAction.SCROLL_FORWARD.equals(id) || AccessibilityAction.SCROLL_BACKWARD.equals(id);
+ }
+
+ private AccessibilityTreeSnapshot currentTree() {
+ AccessibilityTreeSnapshot tree = implementation.getAccessibilityTreeSnapshot();
+ synchronizeIds(tree);
+ return tree;
+ }
+
+ private void synchronizeIds(AccessibilityTreeSnapshot tree) {
+ if (mappedGeneration == tree.getGeneration()) {
+ return;
+ }
+ Set liveSemanticIds = tree.getNodes().keySet();
+ Iterator> virtualIterator = virtualIds.entrySet().iterator();
+ while (virtualIterator.hasNext()) {
+ Map.Entry entry = virtualIterator.next();
+ if (!liveSemanticIds.contains(entry.getKey())) {
+ semanticIds.remove(entry.getValue());
+ virtualIterator.remove();
+ }
+ }
+ for (Long semanticId : liveSemanticIds) {
+ virtualIdFor(semanticId.longValue());
+ }
+
+ Set liveActionKeys = new HashSet();
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ for (AccessibilityAction action : node.getActions()) {
+ if (!isStandard(action.getId())) {
+ liveActionKeys.add(actionKey(node.getId(), action.getId()));
+ }
+ }
+ }
+ Iterator> actionIterator = customActionIds.entrySet().iterator();
+ while (actionIterator.hasNext()) {
+ Map.Entry entry = actionIterator.next();
+ if (!liveActionKeys.contains(entry.getKey())) {
+ customActionKeys.remove(entry.getValue());
+ actionIterator.remove();
+ }
+ }
+ if (accessibilityFocusedId != Integer.MIN_VALUE && !semanticIds.containsKey(
+ Integer.valueOf(accessibilityFocusedId))) {
+ accessibilityFocusedId = Integer.MIN_VALUE;
+ }
+ mappedGeneration = tree.getGeneration();
+ }
+
+ private AccessibilityNodeSnapshot nodeForVirtualId(AccessibilityTreeSnapshot tree, int virtualId) {
+ Long semanticId = semanticIds.get(Integer.valueOf(virtualId));
+ return semanticId == null ? null : tree.getNode(semanticId.longValue());
+ }
+
+ private int virtualIdFor(long semanticId) {
+ Long key = Long.valueOf(semanticId);
+ Integer existing = virtualIds.get(key);
+ if (existing != null) {
+ return existing.intValue();
+ }
+ int candidate = nextVirtualId;
+ while (candidate == AccessibilityNodeProvider.HOST_VIEW_ID
+ || semanticIds.containsKey(Integer.valueOf(candidate))) {
+ candidate = nextVirtualId(candidate);
+ }
+ nextVirtualId = nextVirtualId(candidate);
+ Integer virtualId = Integer.valueOf(candidate);
+ virtualIds.put(key, virtualId);
+ semanticIds.put(virtualId, key);
+ return candidate;
+ }
+
+ private int nextVirtualId(int current) {
+ return current == Integer.MAX_VALUE ? 1 : current + 1;
+ }
+
+ private int customActionId(long nodeId, String id) {
+ String key = actionKey(nodeId, id);
+ Integer existing = customActionIds.get(key);
+ if (existing != null) {
+ return existing.intValue();
+ }
+ int candidate = nextCustomActionId;
+ while (customActionKeys.containsKey(Integer.valueOf(candidate))) {
+ candidate = nextCustomActionId(candidate);
+ }
+ nextCustomActionId = nextCustomActionId(candidate);
+ Integer actionId = Integer.valueOf(candidate);
+ customActionIds.put(key, actionId);
+ customActionKeys.put(actionId, key);
+ return candidate;
+ }
+
+ private int nextCustomActionId(int current) {
+ return current == Integer.MAX_VALUE ? CUSTOM_ACTION_BASE : current + 1;
+ }
+
+ private String actionKey(long nodeId, String actionId) {
+ return nodeId + "\n" + actionId;
+ }
+}
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
index 1b26a84c9cc..3150f646bb9 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -53,21 +53,24 @@
import android.media.AudioManager;
import android.net.Uri;
import android.os.Vibrator;
-import android.os.PowerManager;
+import android.os.PowerManager;
+import android.provider.Settings;
import android.telephony.TelephonyManager;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
import android.view.KeyEvent;
import android.view.View;
-import android.view.ViewGroup;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityManager;
import android.view.Window;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.RelativeLayout;
import android.widget.TextView;
-import com.codename1.ui.BrowserComponent;
+import com.codename1.ui.BrowserComponent;
+import com.codename1.ui.AccessibilityColorVisionDeficiency;
import com.codename1.ui.Component;
import com.codename1.ui.Font;
@@ -317,7 +320,9 @@ public static void setActivity(CodenameOneActivity aActivity) {
}
}
- CodenameOneSurface myView = null;
+ CodenameOneSurface myView = null;
+ private AndroidAccessibilityProvider accessibilityProvider;
+ private volatile boolean accessibilityTreeUpdateRequired;
CodenameOneTextPaint defaultFont;
private final char[] tmpchar = new char[1];
private final Rect tmprect = new Rect();
@@ -1395,7 +1400,7 @@ public boolean isLargerTextEnabled() {
}
@Override
- public float getLargerTextScale() {
+ public float getLargerTextScale() {
try {
Configuration configuration;
if (getActivity() != null) {
@@ -1577,12 +1582,16 @@ public void run() {
}
deinitializing = false;
}
- if (nativePeers.size() > 0) {
- for (int i = 0; i < nativePeers.size(); i++) {
- ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit();
- }
- }
- if (relativeLayout != null) {
+ if (nativePeers.size() > 0) {
+ for (int i = 0; i < nativePeers.size(); i++) {
+ ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit();
+ }
+ }
+ if (accessibilityProvider != null) {
+ accessibilityProvider.dispose();
+ accessibilityProvider = null;
+ }
+ if (relativeLayout != null) {
relativeLayout.removeAllViews();
}
relativeLayout = null;
@@ -1628,7 +1637,18 @@ private void initSurface() {
superPeerMode = true;
myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this);
}
- myView.getAndroidView().setVisibility(View.VISIBLE);
+ myView.getAndroidView().setVisibility(View.VISIBLE);
+
+ if (Build.VERSION.SDK_INT >= 16) {
+ final View semanticHost = myView.getAndroidView();
+ accessibilityProvider = new AndroidAccessibilityProvider(semanticHost, this);
+ semanticHost.setAccessibilityDelegate(new View.AccessibilityDelegate() {
+ @Override
+ public android.view.accessibility.AccessibilityNodeProvider getAccessibilityNodeProvider(View host) {
+ return accessibilityProvider;
+ }
+ });
+ }
relativeLayout.addView(myView.getAndroidView());
myView.getAndroidView().setVisibility(View.VISIBLE);
@@ -12891,7 +12911,7 @@ public void run() {
}
@Override
- public void announceForAccessibility(final Component cmp, final String text) {
+ public void announceForAccessibility(final Component cmp, final String text) {
final Activity act = getActivity();
if (act == null) {
return;
@@ -12925,7 +12945,124 @@ public void run() {
}
}
});
- }
+ }
+
+ @Override
+ public boolean isHighContrastEnabled() {
+ try {
+ AccessibilityManager manager = (AccessibilityManager)getContext()
+ .getSystemService(Context.ACCESSIBILITY_SERVICE);
+ if (android.os.Build.VERSION.SDK_INT >= 21 && manager != null) {
+ Object enabled = AccessibilityManager.class.getMethod("isHighTextContrastEnabled")
+ .invoke(manager);
+ return enabled instanceof Boolean && ((Boolean)enabled).booleanValue();
+ }
+ } catch (Throwable t) {
+ // Fall through to the secure settings used by older Android stubs.
+ }
+ return secureSettingEnabled("high_text_contrast_enabled")
+ || secureSettingEnabled("accessibility_display_high_text_contrast_enabled");
+ }
+
+ @Override
+ public boolean isDifferentiateWithoutColorEnabled() {
+ return secureSettingEnabled("accessibility_display_daltonizer_enabled");
+ }
+
+ @Override
+ public AccessibilityColorVisionDeficiency getColorVisionDeficiency() {
+ if (!secureSettingEnabled("accessibility_display_daltonizer_enabled")) {
+ return AccessibilityColorVisionDeficiency.NONE;
+ }
+ try {
+ int mode = Settings.Secure.getInt(getContext().getContentResolver(),
+ "accessibility_display_daltonizer");
+ switch (mode) {
+ case 0: return AccessibilityColorVisionDeficiency.MONOCHROMACY;
+ case 11: return AccessibilityColorVisionDeficiency.PROTANOPIA;
+ case 12: return AccessibilityColorVisionDeficiency.DEUTERANOPIA;
+ case 13: return AccessibilityColorVisionDeficiency.TRITANOPIA;
+ default: return AccessibilityColorVisionDeficiency.UNKNOWN;
+ }
+ } catch (Throwable t) {
+ return AccessibilityColorVisionDeficiency.UNKNOWN;
+ }
+ }
+
+ @Override
+ public boolean isReduceMotionEnabled() {
+ try {
+ return Settings.Global.getFloat(getContext().getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean isBoldTextEnabled() {
+ try {
+ Object value = Configuration.class.getField("fontWeightAdjustment")
+ .get(getContext().getResources().getConfiguration());
+ return value instanceof Integer && ((Integer)value).intValue() >= 300;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean isInvertColorsEnabled() {
+ return secureSettingEnabled("accessibility_display_inversion_enabled");
+ }
+
+ @Override
+ public boolean isGrayscaleEnabled() {
+ return getColorVisionDeficiency() == AccessibilityColorVisionDeficiency.MONOCHROMACY;
+ }
+
+ @Override
+ public boolean isScreenReaderEnabled() {
+ try {
+ AccessibilityManager manager = (AccessibilityManager)getContext()
+ .getSystemService(Context.ACCESSIBILITY_SERVICE);
+ return manager != null && manager.isEnabled() && manager.isTouchExplorationEnabled();
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ private boolean secureSettingEnabled(String key) {
+ try {
+ return Settings.Secure.getInt(getContext().getContentResolver(), key, 0) == 1;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ @Override
+ public void accessibilityTreeChanged(final int changeType) {
+ final Activity act = getActivity();
+ if (act == null || accessibilityProvider == null) return;
+ act.runOnUiThread(new Runnable() {
+ public void run() {
+ if (accessibilityProvider != null) accessibilityProvider.invalidate(changeType);
+ }
+ });
+ }
+
+ @Override
+ public boolean isAccessibilityTreeSupported() {
+ return Build.VERSION.SDK_INT >= 16;
+ }
+
+ @Override
+ public boolean isAccessibilityTreeUpdateRequired() {
+ return accessibilityTreeUpdateRequired;
+ }
+
+ void setAccessibilityTreeUpdateRequired(boolean required) {
+ accessibilityTreeUpdateRequired = required;
+ }
// ================================================================
// Crypto bridge -- routes com.codename1.security onto the standard
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java
index e2463f15ceb..c5738bf3af0 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java
@@ -22,8 +22,14 @@
import com.codename1.ui.list.ContainerList;
import com.codename1.ui.plaf.UIManager;
import com.codename1.ui.util.Resources;
+import com.codename1.ui.accessibility.AccessibilityAssertions;
+import com.codename1.ui.accessibility.AccessibilityInspector;
+import com.codename1.ui.accessibility.AccessibilityIssue;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
import java.awt.BorderLayout;
import java.awt.Desktop;
+import java.awt.Toolkit;
+import java.awt.datatransfer.StringSelection;
import java.awt.event.ActionEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
@@ -103,6 +109,31 @@ public JFrame showInFrame() {
frame.setVisible(true);
return frame;
}
+
+ /** Audits the current form and displays the result. */
+ public void showAccessibilityAudit() {
+ com.codename1.ui.CN.callSerially(new Runnable() {
+ public void run() {
+ Form form = Display.getInstance().getCurrent();
+ if (form == null) return;
+ AccessibilityTreeSnapshot snapshot = AccessibilityInspector.snapshot(form);
+ final List issues = AccessibilityAssertions.audit(snapshot);
+ final StringBuilder message = new StringBuilder();
+ for (AccessibilityIssue issue : issues) {
+ if (message.length() > 0) message.append('\n');
+ message.append(issue.toString());
+ }
+ SwingUtilities.invokeLater(new Runnable() {
+ public void run() {
+ JOptionPane.showMessageDialog(ComponentTreeInspector.this,
+ issues.isEmpty() ? "No accessibility issues found" : message.toString(),
+ "Accessibility Audit", issues.isEmpty()
+ ? JOptionPane.INFORMATION_MESSAGE : JOptionPane.WARNING_MESSAGE);
+ }
+ });
+ }
+ });
+ }
public void dispose() {
if (frame != null) {
@@ -179,6 +210,34 @@ public void run() {
});
contextMenu.add(printComponent);
+ JMenuItem copyAccessibilityTree = new JMenuItem("Copy Accessibility Tree JSON");
+ copyAccessibilityTree.addActionListener(new java.awt.event.ActionListener() {
+ public void actionPerformed(java.awt.event.ActionEvent evt) {
+ com.codename1.ui.CN.callSerially(new Runnable() {
+ public void run() {
+ Form form = Display.getInstance().getCurrent();
+ if (form == null) return;
+ final String json = AccessibilityInspector.snapshot(form).toJson();
+ SwingUtilities.invokeLater(new Runnable() {
+ public void run() {
+ Toolkit.getDefaultToolkit().getSystemClipboard().setContents(
+ new StringSelection(json), null);
+ }
+ });
+ }
+ });
+ }
+ });
+ contextMenu.add(copyAccessibilityTree);
+
+ JMenuItem auditAccessibilityTree = new JMenuItem("Audit Accessibility Tree");
+ auditAccessibilityTree.addActionListener(new java.awt.event.ActionListener() {
+ public void actionPerformed(java.awt.event.ActionEvent evt) {
+ showAccessibilityAudit();
+ }
+ });
+ contextMenu.add(auditAccessibilityTree);
+
//if (currentComponent instanceof Container) {
JMenuItem revalidate = new JMenuItem("Revalidate");
revalidate.addActionListener(new java.awt.event.ActionListener() {
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java
index bc57753c841..80badf36737 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java
@@ -76,7 +76,9 @@ public class Executor {
String n = System.getProperty("os.name");
if (n != null && n.startsWith("Mac")) {
IS_MAC = true;
- System.setProperty("apple.laf.useScreenMenuBar", "true");
+ if (System.getProperty("apple.laf.useScreenMenuBar") == null) {
+ System.setProperty("apple.laf.useScreenMenuBar", "true");
+ }
System.setProperty("com.apple.mrj.application.apple.menu.about.name", "Codename One GUIBuilder");
System.setProperty("apple.eawt.quitStrategy", "CLOSE_ALL_WINDOWS");
} else {
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEAccessibility.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEAccessibility.java
new file mode 100644
index 00000000000..312f410681e
--- /dev/null
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEAccessibility.java
@@ -0,0 +1,309 @@
+/*
+ * 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.impl.javase;
+
+import com.codename1.ui.accessibility.AccessibilityAction;
+import com.codename1.ui.accessibility.AccessibilityCheckedState;
+import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
+import com.codename1.ui.accessibility.AccessibilityRange;
+import com.codename1.ui.accessibility.AccessibilityRole;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
+import java.awt.Color;
+import java.awt.Cursor;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.event.FocusListener;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import javax.accessibility.Accessible;
+import javax.accessibility.AccessibleAction;
+import javax.accessibility.AccessibleComponent;
+import javax.accessibility.AccessibleContext;
+import javax.accessibility.AccessibleRole;
+import javax.accessibility.AccessibleState;
+import javax.accessibility.AccessibleStateSet;
+import javax.accessibility.AccessibleValue;
+import javax.swing.JPanel;
+
+/** Swing/Java Access Bridge projection of the lightweight semantic tree. */
+final class JavaSEAccessibility {
+ private final JPanel canvas;
+ private final JavaSEPort implementation;
+ private final RootContext root = new RootContext();
+ private final Map cache = new HashMap();
+
+ JavaSEAccessibility(JPanel canvas, JavaSEPort implementation) {
+ this.canvas = canvas;
+ this.implementation = implementation;
+ }
+
+ AccessibleContext getContext() {
+ return root;
+ }
+
+ void changed(int changeType) {
+ root.firePropertyChange(AccessibleContext.ACCESSIBLE_VISIBLE_DATA_PROPERTY, null,
+ Integer.valueOf(changeType));
+ }
+
+ private AccessibilityTreeSnapshot tree() {
+ return implementation.getAccessibilityTreeSnapshot();
+ }
+
+ private VirtualAccessible accessible(long id) {
+ VirtualAccessible value = cache.get(Long.valueOf(id));
+ if (value == null) {
+ value = new VirtualAccessible(id);
+ cache.put(Long.valueOf(id), value);
+ }
+ return value;
+ }
+
+ private final class RootContext extends AccessibleContext implements AccessibleComponent {
+ RootContext() {
+ setAccessibleParent(canvas);
+ }
+
+ public String getAccessibleName() { return "Codename One application"; }
+ public String getAccessibleDescription() { return "Lightweight component accessibility tree"; }
+ public AccessibleRole getAccessibleRole() { return AccessibleRole.PANEL; }
+ public AccessibleStateSet getAccessibleStateSet() {
+ AccessibleStateSet states = new AccessibleStateSet();
+ if (canvas.isEnabled()) states.add(AccessibleState.ENABLED);
+ if (canvas.isVisible()) states.add(AccessibleState.VISIBLE);
+ if (canvas.isShowing()) states.add(AccessibleState.SHOWING);
+ states.add(AccessibleState.FOCUSABLE);
+ if (canvas.hasFocus()) states.add(AccessibleState.FOCUSED);
+ return states;
+ }
+ public int getAccessibleIndexInParent() { return -1; }
+ public int getAccessibleChildrenCount() { return tree().getRootIds().size(); }
+ public Accessible getAccessibleChild(int i) {
+ List roots = tree().getRootIds();
+ return i < 0 || i >= roots.size() ? null : accessible(roots.get(i).longValue());
+ }
+ public Locale getLocale() { return Locale.getDefault(); }
+ public AccessibleComponent getAccessibleComponent() { return this; }
+ public Color getBackground() { return canvas.getBackground(); }
+ public void setBackground(Color c) { canvas.setBackground(c); }
+ public Color getForeground() { return canvas.getForeground(); }
+ public void setForeground(Color c) { canvas.setForeground(c); }
+ public Cursor getCursor() { return canvas.getCursor(); }
+ public void setCursor(Cursor c) { canvas.setCursor(c); }
+ public Font getFont() { return canvas.getFont(); }
+ public void setFont(Font f) { canvas.setFont(f); }
+ public FontMetrics getFontMetrics(Font f) { return canvas.getFontMetrics(f); }
+ public boolean isEnabled() { return canvas.isEnabled(); }
+ public void setEnabled(boolean b) { canvas.setEnabled(b); }
+ public boolean isVisible() { return canvas.isVisible(); }
+ public void setVisible(boolean b) { canvas.setVisible(b); }
+ public boolean isShowing() { return canvas.isShowing(); }
+ public boolean contains(Point p) { return canvas.contains(p); }
+ public Point getLocationOnScreen() { return canvas.getLocationOnScreen(); }
+ public Point getLocation() { return canvas.getLocation(); }
+ public void setLocation(Point p) { canvas.setLocation(p); }
+ public Rectangle getBounds() { return canvas.getBounds(); }
+ public void setBounds(Rectangle r) { canvas.setBounds(r); }
+ public Dimension getSize() { return canvas.getSize(); }
+ public void setSize(Dimension d) { canvas.setSize(d); }
+ public Accessible getAccessibleAt(Point p) {
+ Point local = new Point(p);
+ AccessibilityNodeSnapshot node = tree().getNodeAt(local.x, local.y);
+ return node == null ? null : accessible(node.getId());
+ }
+ public boolean isFocusTraversable() { return true; }
+ public void requestFocus() { canvas.requestFocus(); }
+ public void addFocusListener(FocusListener l) { canvas.addFocusListener(l); }
+ public void removeFocusListener(FocusListener l) { canvas.removeFocusListener(l); }
+ }
+
+ private final class VirtualAccessible implements Accessible {
+ private final long id;
+ private final NodeContext context;
+
+ VirtualAccessible(long id) {
+ this.id = id;
+ context = new NodeContext();
+ }
+
+ public AccessibleContext getAccessibleContext() { return context; }
+
+ private AccessibilityNodeSnapshot node() { return tree().getNode(id); }
+
+ private final class NodeContext extends AccessibleContext
+ implements AccessibleComponent, AccessibleAction, AccessibleValue {
+ NodeContext() {
+ setAccessibleParent(canvas);
+ }
+
+ public String getAccessibleName() { return node() == null ? null : node().getLabel(); }
+ public void setAccessibleName(String name) { }
+ public String getAccessibleDescription() {
+ AccessibilityNodeSnapshot n = node();
+ if (n == null) return null;
+ String value = n.getDescription();
+ if (n.getHint() != null) value = value == null ? n.getHint() : value + ". " + n.getHint();
+ if (n.getValidationError() != null) value = value == null ? n.getValidationError() : value + ". " + n.getValidationError();
+ return value;
+ }
+ public void setAccessibleDescription(String description) { }
+ public AccessibleRole getAccessibleRole() { return role(node() == null ? AccessibilityRole.GENERIC : node().getRole()); }
+ public AccessibleStateSet getAccessibleStateSet() { return states(node()); }
+ public Accessible getAccessibleParent() {
+ AccessibilityNodeSnapshot n = node();
+ return n == null || n.getParentId() < 0 ? canvas : accessible(n.getParentId());
+ }
+ public int getAccessibleIndexInParent() {
+ AccessibilityNodeSnapshot n = node();
+ if (n == null) return -1;
+ List siblings = n.getParentId() < 0 ? tree().getRootIds()
+ : tree().getNode(n.getParentId()).getChildIds();
+ return siblings.indexOf(Long.valueOf(id));
+ }
+ public int getAccessibleChildrenCount() { return node() == null ? 0 : node().getChildIds().size(); }
+ public Accessible getAccessibleChild(int i) {
+ AccessibilityNodeSnapshot n = node();
+ if (n == null || i < 0 || i >= n.getChildIds().size()) return null;
+ return accessible(n.getChildIds().get(i).longValue());
+ }
+ public Locale getLocale() { return Locale.getDefault(); }
+ public AccessibleComponent getAccessibleComponent() { return this; }
+ public AccessibleAction getAccessibleAction() { return node() != null && !node().getActions().isEmpty() ? this : null; }
+ public AccessibleValue getAccessibleValue() { return node() != null && node().getRange() != null ? this : null; }
+ public int getAccessibleActionCount() { return node() == null ? 0 : node().getActions().size(); }
+ public String getAccessibleActionDescription(int i) {
+ if (node() == null || i < 0 || i >= node().getActions().size()) return null;
+ AccessibilityAction action = node().getActions().get(i);
+ return action.getLabel() == null ? action.getId() : action.getLabel();
+ }
+ public boolean doAccessibleAction(int i) {
+ if (node() == null || i < 0 || i >= node().getActions().size()) return false;
+ return implementation.performAccessibilityAction(id, node().getActions().get(i).getId(), null);
+ }
+ public Number getCurrentAccessibleValue() { return node() == null || node().getRange() == null ? null : Double.valueOf(node().getRange().getCurrent()); }
+ public boolean setCurrentAccessibleValue(Number value) {
+ return value != null && implementation.performAccessibilityAction(id, AccessibilityAction.SET_VALUE, value);
+ }
+ public Number getMinimumAccessibleValue() { return node() == null || node().getRange() == null ? null : Double.valueOf(node().getRange().getMinimum()); }
+ public Number getMaximumAccessibleValue() { return node() == null || node().getRange() == null ? null : Double.valueOf(node().getRange().getMaximum()); }
+ public Color getBackground() { return canvas.getBackground(); }
+ public void setBackground(Color c) { }
+ public Color getForeground() { return canvas.getForeground(); }
+ public void setForeground(Color c) { }
+ public Cursor getCursor() { return canvas.getCursor(); }
+ public void setCursor(Cursor c) { }
+ public Font getFont() { return canvas.getFont(); }
+ public void setFont(Font f) { }
+ public FontMetrics getFontMetrics(Font f) { return canvas.getFontMetrics(f); }
+ public boolean isEnabled() { return node() != null && !Boolean.FALSE.equals(node().getEnabled()); }
+ public void setEnabled(boolean b) { }
+ public boolean isVisible() { return node() != null && node().getBounds().getWidth() > 0 && node().getBounds().getHeight() > 0; }
+ public void setVisible(boolean b) { }
+ public boolean isShowing() { return canvas.isShowing() && isVisible(); }
+ public boolean contains(Point p) { return getBounds().contains(p); }
+ public Point getLocationOnScreen() {
+ Point rootLocation = canvas.getLocationOnScreen();
+ Rectangle bounds = getBounds();
+ return new Point(rootLocation.x + bounds.x, rootLocation.y + bounds.y);
+ }
+ public Point getLocation() { Rectangle b = getBounds(); return new Point(b.x, b.y); }
+ public void setLocation(Point p) { }
+ public Rectangle getBounds() {
+ if (node() == null) return new Rectangle();
+ com.codename1.ui.geom.Rectangle b = node().getBounds();
+ return new Rectangle(b.getX(), b.getY(), b.getWidth(), b.getHeight());
+ }
+ public void setBounds(Rectangle r) { }
+ public Dimension getSize() { Rectangle b = getBounds(); return new Dimension(b.width, b.height); }
+ public void setSize(Dimension d) { }
+ public Accessible getAccessibleAt(Point p) {
+ AccessibilityNodeSnapshot n = tree().getNodeAt(p.x, p.y);
+ return n == null ? null : accessible(n.getId());
+ }
+ public boolean isFocusTraversable() { return node() != null && node().isFocusable(); }
+ public void requestFocus() { implementation.performAccessibilityAction(id, AccessibilityAction.FOCUS, null); }
+ public void addFocusListener(FocusListener l) { canvas.addFocusListener(l); }
+ public void removeFocusListener(FocusListener l) { canvas.removeFocusListener(l); }
+ }
+ }
+
+ private AccessibleStateSet states(AccessibilityNodeSnapshot node) {
+ AccessibleStateSet states = new AccessibleStateSet();
+ if (node == null) return states;
+ if (!Boolean.FALSE.equals(node.getEnabled())) states.add(AccessibleState.ENABLED);
+ if (node.getBounds().getWidth() > 0 && node.getBounds().getHeight() > 0) {
+ states.add(AccessibleState.VISIBLE);
+ if (canvas.isShowing()) states.add(AccessibleState.SHOWING);
+ }
+ if (node.isFocusable()) states.add(AccessibleState.FOCUSABLE);
+ if (node.isFocused()) states.add(AccessibleState.FOCUSED);
+ if (Boolean.TRUE.equals(node.getSelected())) states.add(AccessibleState.SELECTED);
+ if (node.getChecked() == AccessibilityCheckedState.CHECKED) states.add(AccessibleState.CHECKED);
+ if (Boolean.TRUE.equals(node.getExpanded())) states.add(AccessibleState.EXPANDED);
+ if (Boolean.FALSE.equals(node.getExpanded())) states.add(AccessibleState.COLLAPSED);
+ if (Boolean.TRUE.equals(node.getBusy())) states.add(AccessibleState.BUSY);
+ if (!Boolean.TRUE.equals(node.getReadOnly()) && (node.getRole() == AccessibilityRole.TEXT_FIELD
+ || node.getRole() == AccessibilityRole.SEARCH_FIELD)) states.add(AccessibleState.EDITABLE);
+ if (Boolean.TRUE.equals(node.getMultiline())) states.add(AccessibleState.MULTI_LINE);
+ return states;
+ }
+
+ private AccessibleRole role(AccessibilityRole role) {
+ switch (role) {
+ case BUTTON: return AccessibleRole.PUSH_BUTTON;
+ case TOGGLE_BUTTON: return AccessibleRole.TOGGLE_BUTTON;
+ case CHECKBOX: return AccessibleRole.CHECK_BOX;
+ case RADIO_BUTTON: return AccessibleRole.RADIO_BUTTON;
+ case SWITCH: return AccessibleRole.TOGGLE_BUTTON;
+ case HEADING:
+ case STATIC_TEXT: return AccessibleRole.LABEL;
+ case LINK: return AccessibleRole.HYPERLINK;
+ case IMAGE: return AccessibleRole.ICON;
+ case TEXT_FIELD:
+ case SEARCH_FIELD: return AccessibleRole.TEXT;
+ case SLIDER: return AccessibleRole.SLIDER;
+ case PROGRESS_BAR: return AccessibleRole.PROGRESS_BAR;
+ case LIST: return AccessibleRole.LIST;
+ case LIST_ITEM: return AccessibleRole.LIST_ITEM;
+ case GRID: return AccessibleRole.TABLE;
+ case CELL: return AccessibleRole.UNKNOWN;
+ case TAB_LIST: return AccessibleRole.PAGE_TAB_LIST;
+ case TAB: return AccessibleRole.PAGE_TAB;
+ case DIALOG: return AccessibleRole.DIALOG;
+ case ALERT: return AccessibleRole.ALERT;
+ case MENU: return AccessibleRole.MENU;
+ case MENU_ITEM: return AccessibleRole.MENU_ITEM;
+ case TOOLBAR: return AccessibleRole.TOOL_BAR;
+ case SCROLL_BAR: return AccessibleRole.SCROLL_BAR;
+ case COMBO_BOX: return AccessibleRole.COMBO_BOX;
+ case TREE: return AccessibleRole.TREE;
+ case SEPARATOR: return AccessibleRole.SEPARATOR;
+ default: return AccessibleRole.PANEL;
+ }
+ }
+}
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index cd310f49673..a6838eb8043 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -39,6 +39,8 @@
import com.codename1.payment.PromotionalOffer;
import com.codename1.printing.PrintResult;
import com.codename1.printing.PrintResultListener;
+import com.codename1.ui.AccessibilityColorVisionDeficiency;
+import com.codename1.ui.accessibility.AccessibilityManager;
import com.codename1.ui.Component;
import com.codename1.ui.Display;
import com.codename1.ui.Font;
@@ -235,6 +237,17 @@ public class JavaSEPort extends CodenameOneImplementation {
// font scaling, so the Larger Text menu only nags them a single time per session.
private boolean largerTextOptInWarningShown = false;
private static final String PREF_LARGER_TEXT_SCALE = "cn1.simulator.largerTextScale";
+ private static final String PREF_ACCESSIBILITY_PREFIX = "cn1.simulator.accessibility.";
+ private boolean highContrastEnabled;
+ private boolean differentiateWithoutColorEnabled;
+ private boolean reduceMotionEnabled;
+ private boolean reduceTransparencyEnabled;
+ private boolean boldTextEnabled;
+ private boolean invertColorsEnabled;
+ private boolean grayscaleEnabled;
+ private boolean onOffSwitchLabelsEnabled;
+ private boolean screenReaderEnabled;
+ private AccessibilityColorVisionDeficiency colorVisionDeficiency = AccessibilityColorVisionDeficiency.NONE;
// Floor below which any persisted window dimension is treated as the
// product of a layout glitch (eg. a pack() with a collapsed canvas, an
@@ -611,7 +624,9 @@ public static double getRetinaScale() {
IS_MAC = false;
}
isWindows = File.separatorChar == '\\';
- System.setProperty("apple.laf.useScreenMenuBar", "true");
+ if (System.getProperty("apple.laf.useScreenMenuBar") == null) {
+ System.setProperty("apple.laf.useScreenMenuBar", "true");
+ }
if(isWindows) {
fontFaceSystem = "ArialUnicodeMS";
@@ -2456,6 +2471,7 @@ public int getCanvasY() {
}
protected class C extends JPanel implements KeyListener, MouseListener, MouseMotionListener, HierarchyBoundsListener, AdjustmentListener, MouseWheelListener {
+ private JavaSEAccessibility cn1Accessibility;
private BufferedImage buffer;
boolean painted;
private Graphics2D g2dInstance;
@@ -2490,6 +2506,23 @@ public void hierarchyChanged(HierarchyEvent e) {
requestFocus();
}
+ @Override
+ public javax.accessibility.AccessibleContext getAccessibleContext() {
+ if (cn1Accessibility == null) {
+ cn1Accessibility = new JavaSEAccessibility(this, JavaSEPort.this);
+ AccessibilityManager.getInstance().invalidateAll();
+ }
+ return cn1Accessibility.getContext();
+ }
+
+ boolean isAccessibilityContextRequested() {
+ return cn1Accessibility != null;
+ }
+
+ void accessibilityChanged(int changeType) {
+ if (cn1Accessibility != null) cn1Accessibility.changed(changeType);
+ }
+
private void installNativeMagnificationListeners() {
installMagnificationWheelFallbackListener();
installNativeMagnificationListener(this);
@@ -2867,7 +2900,9 @@ private boolean drawScreenBuffer(java.awt.Graphics g) {
}
if (isEnabled()) {
- g.drawImage(buffer, (int) ((getScreenCoordinates().getX() + x) * zoomLevel), (int) ((getScreenCoordinates().getY() + y) * zoomLevel), this);
+ g.drawImage(accessibilityFilteredImage(buffer),
+ (int) ((getScreenCoordinates().getX() + x) * zoomLevel),
+ (int) ((getScreenCoordinates().getY() + y) * zoomLevel), this);
} else {
g.setColor(Color.WHITE);
g.fillRect(x + (int) (getSkin().getWidth() * zoomLevel), y, getWidth(), getHeight());
@@ -2920,7 +2955,7 @@ private boolean drawScreenBuffer(java.awt.Graphics g) {
bg.dispose();
painted = true;
}
- g.drawImage(buffer, x, y, this);
+ g.drawImage(accessibilityFilteredImage(buffer), x, y, this);
}
((Graphics2D)g).setTransform(t);
return painted;
@@ -3767,6 +3802,25 @@ public JavaSEPort() {
instance = this;
}
+ @Override
+ public void accessibilityTreeChanged(final int changeType) {
+ EventQueue.invokeLater(new Runnable() {
+ public void run() {
+ if (canvas != null) canvas.accessibilityChanged(changeType);
+ }
+ });
+ }
+
+ @Override
+ public boolean isAccessibilityTreeSupported() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccessibilityTreeUpdateRequired() {
+ return screenReaderEnabled || canvas != null && canvas.isAccessibilityContextRequested();
+ }
+
public void paintDirty() {
super.paintDirty();
}
@@ -6382,6 +6436,7 @@ public void actionPerformed(ActionEvent e) {
});
final JMenu largerTextMenu = installLargerTextMenu(simulateMenu, pref, frm);
+ final JMenu accessibilityPreferencesMenu = installAccessibilityPreferencesMenu(pref);
final JMenu notificationBackgroundMenu = installNotificationBackgroundSimulationMenu(simulateMenu);
@@ -6592,6 +6647,7 @@ public void actionPerformed(ActionEvent e) {
simulateMenu.addSeparator();
simulateMenu.add(darkLightModeMenu);
simulateMenu.add(largerTextMenu);
+ simulateMenu.add(accessibilityPreferencesMenu);
simulateMenu.add(purchaseMenu);
simulateMenu.add(permFlag);
simulateMenu.add(notificationBackgroundMenu);
@@ -7549,6 +7605,118 @@ public void actionPerformed(ActionEvent e) {
return largerTextMenu;
}
+ private JMenu installAccessibilityPreferencesMenu(final Preferences pref) {
+ highContrastEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "highContrast", false);
+ differentiateWithoutColorEnabled = pref.getBoolean(
+ PREF_ACCESSIBILITY_PREFIX + "differentiateWithoutColor", false);
+ reduceMotionEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "reduceMotion", false);
+ reduceTransparencyEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "reduceTransparency", false);
+ boldTextEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "boldText", false);
+ invertColorsEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "invertColors", false);
+ grayscaleEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "grayscale", false);
+ onOffSwitchLabelsEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "switchLabels", false);
+ screenReaderEnabled = pref.getBoolean(PREF_ACCESSIBILITY_PREFIX + "screenReader", false);
+ try {
+ colorVisionDeficiency = AccessibilityColorVisionDeficiency.valueOf(
+ pref.get(PREF_ACCESSIBILITY_PREFIX + "colorVision",
+ AccessibilityColorVisionDeficiency.NONE.name()));
+ } catch (IllegalArgumentException ex) {
+ colorVisionDeficiency = AccessibilityColorVisionDeficiency.NONE;
+ }
+
+ JMenu menu = new JMenu("Accessibility Preferences");
+ registerMenuWithBlit(menu);
+ menu.add(accessibilityPreferenceItem("High Contrast", highContrastEnabled, pref, "highContrast", 0));
+ menu.add(accessibilityPreferenceItem("Differentiate Without Color",
+ differentiateWithoutColorEnabled, pref, "differentiateWithoutColor", 1));
+ menu.add(createColorVisionMenu(pref));
+ menu.addSeparator();
+ menu.add(accessibilityPreferenceItem("Reduce Motion", reduceMotionEnabled, pref, "reduceMotion", 2));
+ menu.add(accessibilityPreferenceItem("Reduce Transparency", reduceTransparencyEnabled,
+ pref, "reduceTransparency", 3));
+ menu.add(accessibilityPreferenceItem("Bold Text", boldTextEnabled, pref, "boldText", 4));
+ menu.add(accessibilityPreferenceItem("Invert Colors", invertColorsEnabled, pref, "invertColors", 5));
+ menu.add(accessibilityPreferenceItem("Grayscale", grayscaleEnabled, pref, "grayscale", 6));
+ menu.add(accessibilityPreferenceItem("On/Off Switch Labels", onOffSwitchLabelsEnabled,
+ pref, "switchLabels", 7));
+ menu.add(accessibilityPreferenceItem("Screen Reader Active", screenReaderEnabled,
+ pref, "screenReader", 8));
+ return menu;
+ }
+
+ private JCheckBoxMenuItem accessibilityPreferenceItem(String label, boolean selected,
+ final Preferences pref, final String key, final int preference) {
+ final JCheckBoxMenuItem item = new JCheckBoxMenuItem(label, selected);
+ item.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ setSimulatorAccessibilityPreference(preference, item.isSelected());
+ pref.putBoolean(PREF_ACCESSIBILITY_PREFIX + key, item.isSelected());
+ refreshThemeOnly();
+ canvas.repaint();
+ }
+ });
+ return item;
+ }
+
+ private JMenu createColorVisionMenu(final Preferences pref) {
+ JMenu menu = new JMenu("Color Vision");
+ ButtonGroup group = new ButtonGroup();
+ AccessibilityColorVisionDeficiency[] modes = {
+ AccessibilityColorVisionDeficiency.NONE,
+ AccessibilityColorVisionDeficiency.PROTANOPIA,
+ AccessibilityColorVisionDeficiency.DEUTERANOPIA,
+ AccessibilityColorVisionDeficiency.TRITANOPIA,
+ AccessibilityColorVisionDeficiency.MONOCHROMACY
+ };
+ String[] labels = {"None", "Protanopia", "Deuteranopia", "Tritanopia", "Monochromacy"};
+ for (int i = 0; i < modes.length; i++) {
+ final AccessibilityColorVisionDeficiency mode = modes[i];
+ JRadioButtonMenuItem item = new JRadioButtonMenuItem(labels[i], colorVisionDeficiency == mode);
+ item.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ colorVisionDeficiency = mode;
+ pref.put(PREF_ACCESSIBILITY_PREFIX + "colorVision", mode.name());
+ canvas.repaint();
+ }
+ });
+ group.add(item);
+ menu.add(item);
+ }
+ return menu;
+ }
+
+ /// Sets a simulated accessibility preference. This is primarily intended for
+ /// automated simulator tests. Preference indexes follow the menu order.
+ public void setSimulatorAccessibilityPreference(int preference, boolean enabled) {
+ switch (preference) {
+ case 0: highContrastEnabled = enabled; break;
+ case 1: differentiateWithoutColorEnabled = enabled; break;
+ case 2: reduceMotionEnabled = enabled; break;
+ case 3: reduceTransparencyEnabled = enabled; break;
+ case 4: boldTextEnabled = enabled; break;
+ case 5: invertColorsEnabled = enabled; break;
+ case 6: grayscaleEnabled = enabled; break;
+ case 7: onOffSwitchLabelsEnabled = enabled; break;
+ case 8:
+ screenReaderEnabled = enabled;
+ if (enabled) {
+ AccessibilityManager.getInstance().invalidateAll();
+ }
+ break;
+ default: throw new IllegalArgumentException("Unknown accessibility preference: " + preference);
+ }
+ }
+
+ /// Sets the simulator's color-vision filter.
+ public void setSimulatorColorVisionDeficiency(AccessibilityColorVisionDeficiency deficiency) {
+ colorVisionDeficiency = deficiency == null ? AccessibilityColorVisionDeficiency.NONE : deficiency;
+ if (canvas != null) {
+ canvas.repaint();
+ }
+ }
+
/// The Larger Text menu only changes the on-screen fonts when the running app
/// has opted into font scaling -- either through the `useLargerTextScaleBool`
/// theme constant or a `UIManager.setUseLargerTextScale(true)` call at startup.
@@ -8064,6 +8232,111 @@ public float getLargerTextScale() {
return largerTextScale;
}
+ @Override
+ public boolean isHighContrastEnabled() {
+ return highContrastEnabled;
+ }
+
+ @Override
+ public boolean isDifferentiateWithoutColorEnabled() {
+ return differentiateWithoutColorEnabled;
+ }
+
+ @Override
+ public AccessibilityColorVisionDeficiency getColorVisionDeficiency() {
+ return colorVisionDeficiency;
+ }
+
+ @Override
+ public boolean isReduceMotionEnabled() {
+ return reduceMotionEnabled;
+ }
+
+ @Override
+ public boolean isReduceTransparencyEnabled() {
+ return reduceTransparencyEnabled;
+ }
+
+ @Override
+ public boolean isBoldTextEnabled() {
+ return boldTextEnabled;
+ }
+
+ @Override
+ public boolean isInvertColorsEnabled() {
+ return invertColorsEnabled;
+ }
+
+ @Override
+ public boolean isGrayscaleEnabled() {
+ return grayscaleEnabled || colorVisionDeficiency == AccessibilityColorVisionDeficiency.MONOCHROMACY;
+ }
+
+ @Override
+ public boolean isOnOffSwitchLabelsEnabled() {
+ return onOffSwitchLabelsEnabled;
+ }
+
+ @Override
+ public boolean isScreenReaderEnabled() {
+ return screenReaderEnabled;
+ }
+
+ private BufferedImage accessibilityFilteredImage(BufferedImage source) {
+ boolean monochrome = grayscaleEnabled
+ || colorVisionDeficiency == AccessibilityColorVisionDeficiency.MONOCHROMACY;
+ if (!invertColorsEnabled && !monochrome
+ && (colorVisionDeficiency == AccessibilityColorVisionDeficiency.NONE
+ || colorVisionDeficiency == AccessibilityColorVisionDeficiency.UNKNOWN)) {
+ return source;
+ }
+ BufferedImage result = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB);
+ int width = source.getWidth();
+ int height = source.getHeight();
+ int[] pixels = source.getRGB(0, 0, width, height, null, 0, width);
+ for (int i = 0; i < pixels.length; i++) {
+ int argb = pixels[i];
+ int transformed = transformAccessibilityColor(
+ (argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF, monochrome);
+ if (invertColorsEnabled) {
+ transformed = 0xFFFFFF ^ transformed;
+ }
+ pixels[i] = (argb & 0xFF000000) | transformed;
+ }
+ result.setRGB(0, 0, width, height, pixels, 0, width);
+ return result;
+ }
+
+ private int transformAccessibilityColor(int red, int green, int blue, boolean monochrome) {
+ if (monochrome) {
+ int gray = clampColor(0.299 * red + 0.587 * green + 0.114 * blue);
+ return (gray << 16) | (gray << 8) | gray;
+ }
+ switch (colorVisionDeficiency) {
+ case PROTANOPIA:
+ return colorMatrix(red, green, blue, .567, .433, 0, .558, .442, 0, 0, .242, .758);
+ case DEUTERANOPIA:
+ return colorMatrix(red, green, blue, .625, .375, 0, .7, .3, 0, 0, .3, .7);
+ case TRITANOPIA:
+ return colorMatrix(red, green, blue, .95, .05, 0, 0, .433, .567, 0, .475, .525);
+ default:
+ return (red << 16) | (green << 8) | blue;
+ }
+ }
+
+ private int colorMatrix(int red, int green, int blue,
+ double rr, double rg, double rb, double gr, double gg, double gb,
+ double br, double bg, double bb) {
+ int transformedRed = clampColor(rr * red + rg * green + rb * blue);
+ int transformedGreen = clampColor(gr * red + gg * green + gb * blue);
+ int transformedBlue = clampColor(br * red + bg * green + bb * blue);
+ return (transformedRed << 16) | (transformedGreen << 8) | transformedBlue;
+ }
+
+ private int clampColor(double value) {
+ return Math.max(0, Math.min(255, (int) Math.round(value)));
+ }
+
private void refreshSkin(final JFrame frm) {
Display.getInstance().callSerially(new Runnable() {
diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
index 4048f72fce7..bab2b2bbb98 100644
--- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
+++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java
@@ -59,6 +59,7 @@
import com.codename1.teavm.jso.util.JSDateFormat;
import com.codename1.teavm.jso.util.JSNumberFormat;
import com.codename1.ui.Accessor;
+import com.codename1.ui.AccessibilityColorVisionDeficiency;
import com.codename1.ui.BrowserComponent;
import com.codename1.ui.BrowserWindow;
import com.codename1.ui.Button;
@@ -87,6 +88,14 @@
import com.codename1.ui.events.FocusListener;
import com.codename1.ui.events.MessageEvent;
import com.codename1.ui.geom.Rectangle;
+import com.codename1.ui.accessibility.AccessibilityAction;
+import com.codename1.ui.accessibility.AccessibilityCheckedState;
+import com.codename1.ui.accessibility.AccessibilityCollectionItemInfo;
+import com.codename1.ui.accessibility.AccessibilityLiveRegion;
+import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
+import com.codename1.ui.accessibility.AccessibilityRange;
+import com.codename1.ui.accessibility.AccessibilityRole;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
import com.codename1.ui.geom.Shape;
import com.codename1.ui.layouts.BorderLayout;
import com.codename1.ui.layouts.BoxLayout;
@@ -571,6 +580,7 @@ private boolean hitTest(int x, int y) {
* Container for all peer components.
*/
HTMLElement peersContainer;
+ private HTMLElement accessibilityContainer;
/**
@@ -1255,6 +1265,13 @@ private void __init() {
peersContainer = (HTMLElement)document.createElement("div");
peersContainer.setAttribute("id", "cn1-peers-container");
outputCanvas.getParentNode().insertBefore(peersContainer, outputCanvas);
+ accessibilityContainer = (HTMLElement)document.createElement("div");
+ accessibilityContainer.setAttribute("id", "cn1-accessibility-tree");
+ accessibilityContainer.setAttribute("role", "application");
+ accessibilityContainer.getStyle().setCssText("position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;pointer-events:none;z-index:2147483646;");
+ outputCanvas.getParentNode().insertBefore(accessibilityContainer, outputCanvas);
+ outputCanvas.setAttribute("role", "presentation");
+ outputCanvas.setAttribute("aria-hidden", "true");
nativeEdt = EasyThread.start("NativeEDT");
@@ -2854,6 +2871,191 @@ public void revalidate() {
}
}
+
+ @Override
+ public void accessibilityTreeChanged(int changeType) {
+ if (accessibilityContainer == null) return;
+ AccessibilityTreeSnapshot tree = getAccessibilityTreeSnapshot();
+ accessibilityContainer.setInnerHTML("");
+ final Map elements = new HashMap();
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ final long nodeId = node.getId();
+ final HTMLElement element = (HTMLElement)document.createElement("div");
+ elements.put(Long.valueOf(nodeId), element);
+ element.setAttribute("data-cn1-accessibility-id", String.valueOf(nodeId));
+ applyAria(element, node);
+ if (node.getParentId() >= 0 && elements.get(Long.valueOf(node.getParentId())) != null) {
+ elements.get(Long.valueOf(node.getParentId())).appendChild(element);
+ } else {
+ accessibilityContainer.appendChild(element);
+ }
+ final AccessibilityAction activate = node.getAction(AccessibilityAction.ACTIVATE);
+ if (activate != null && activate.isEnabled()) {
+ element.addEventListener("click", new EventListener() {
+ public void handleEvent(Event event) {
+ event.preventDefault();
+ event.stopPropagation();
+ performAccessibilityAction(nodeId, AccessibilityAction.ACTIVATE, null);
+ }
+ });
+ }
+ final AccessibilityAction focus = node.getAction(AccessibilityAction.FOCUS);
+ if (focus != null && focus.isEnabled()) {
+ element.addEventListener("focus", new EventListener() {
+ public void handleEvent(Event event) {
+ performAccessibilityAction(nodeId, AccessibilityAction.FOCUS, null);
+ }
+ });
+ }
+ element.addEventListener("keydown", new EventListener() {
+ public void handleEvent(Event event) {
+ JSOImplementations.KeyEvent key = (JSOImplementations.KeyEvent)event;
+ int code = key.getKeyCode();
+ if ((code == 13 || code == 32) && activate != null) {
+ event.preventDefault();
+ performAccessibilityAction(nodeId, AccessibilityAction.ACTIVATE, null);
+ } else if (code == 38 || code == 39) {
+ performAccessibilityAction(nodeId, AccessibilityAction.INCREMENT, null);
+ } else if (code == 37 || code == 40) {
+ performAccessibilityAction(nodeId, AccessibilityAction.DECREMENT, null);
+ }
+ }
+ });
+ addWebCustomActions(element, node);
+ }
+ }
+
+ @Override
+ public boolean isAccessibilityTreeSupported() {
+ return true;
+ }
+
+ private void applyAria(HTMLElement element, AccessibilityNodeSnapshot node) {
+ String role = ariaRole(node.getRole());
+ if (role != null) element.setAttribute("role", role);
+ if (node.getLabel() != null) element.setAttribute("aria-label", node.getLabel());
+ String description = node.getDescription();
+ if (node.getHint() != null) description = description == null ? node.getHint() : description + ". " + node.getHint();
+ if (node.getValidationError() != null) description = description == null ? node.getValidationError() : description + ". " + node.getValidationError();
+ if (description != null) element.setAttribute("aria-description", description);
+ if (node.getIdentifier() != null) element.setAttribute("id", node.getIdentifier());
+ if (node.getRoleDescription() != null) element.setAttribute("aria-roledescription", node.getRoleDescription());
+ if (node.getValue() != null) element.setAttribute("aria-valuetext", node.getValue());
+ if (node.getSelected() != null) element.setAttribute("aria-selected", String.valueOf(node.getSelected()));
+ if (node.getExpanded() != null) element.setAttribute("aria-expanded", String.valueOf(node.getExpanded()));
+ if (node.getEnabled() != null && !node.getEnabled().booleanValue()) element.setAttribute("aria-disabled", "true");
+ if (node.getInvalid() != null) element.setAttribute("aria-invalid", String.valueOf(node.getInvalid()));
+ if (node.getBusy() != null) element.setAttribute("aria-busy", String.valueOf(node.getBusy()));
+ if (node.getReadOnly() != null) element.setAttribute("aria-readonly", String.valueOf(node.getReadOnly()));
+ if (node.getRequired() != null) element.setAttribute("aria-required", String.valueOf(node.getRequired()));
+ if (node.getMultiline() != null) element.setAttribute("aria-multiline", String.valueOf(node.getMultiline()));
+ if (node.getCurrent() != null && node.getCurrent().booleanValue()) element.setAttribute("aria-current", "true");
+ if (node.isModal()) element.setAttribute("aria-modal", "true");
+ if (node.getHeadingLevel() > 0) element.setAttribute("aria-level", String.valueOf(node.getHeadingLevel()));
+ if (node.getChecked() != AccessibilityCheckedState.UNSPECIFIED) {
+ element.setAttribute("aria-checked", node.getChecked() == AccessibilityCheckedState.MIXED
+ ? "mixed" : String.valueOf(node.getChecked() == AccessibilityCheckedState.CHECKED));
+ }
+ if (node.getPressed() != null) element.setAttribute("aria-pressed", String.valueOf(node.getPressed()));
+ if (node.getLiveRegion() != AccessibilityLiveRegion.OFF) {
+ element.setAttribute("aria-live", node.getLiveRegion() == AccessibilityLiveRegion.ASSERTIVE ? "assertive" : "polite");
+ element.setAttribute("aria-atomic", "true");
+ }
+ AccessibilityRange range = node.getRange();
+ if (range != null) {
+ element.setAttribute("aria-valuemin", String.valueOf(range.getMinimum()));
+ element.setAttribute("aria-valuemax", String.valueOf(range.getMaximum()));
+ element.setAttribute("aria-valuenow", String.valueOf(range.getCurrent()));
+ if (range.getText() != null) element.setAttribute("aria-valuetext", range.getText());
+ }
+ AccessibilityCollectionItemInfo item = node.getCollectionItemInfo();
+ if (item != null) {
+ if (item.getPositionInSet() > 0) element.setAttribute("aria-posinset", String.valueOf(item.getPositionInSet()));
+ if (item.getSetSize() != 0) element.setAttribute("aria-setsize", String.valueOf(item.getSetSize()));
+ if (item.getLevel() > 0) element.setAttribute("aria-level", String.valueOf(item.getLevel()));
+ if (item.getRowIndex() >= 0) element.setAttribute("aria-rowindex", String.valueOf(item.getRowIndex() + 1));
+ if (item.getColumnIndex() >= 0) element.setAttribute("aria-colindex", String.valueOf(item.getColumnIndex() + 1));
+ if (item.getRowSpan() > 1) element.setAttribute("aria-rowspan", String.valueOf(item.getRowSpan()));
+ if (item.getColumnSpan() > 1) element.setAttribute("aria-colspan", String.valueOf(item.getColumnSpan()));
+ }
+ Rectangle bounds = node.getBounds();
+ double ratio = getDevicePixelRatio();
+ element.getStyle().setCssText("position:absolute;opacity:0.001;pointer-events:none;overflow:hidden;"
+ + "left:" + bounds.getX() / ratio + "px;top:" + bounds.getY() / ratio + "px;"
+ + "width:" + Math.max(1, bounds.getWidth()) / ratio + "px;height:"
+ + Math.max(1, bounds.getHeight()) / ratio + "px;");
+ if (node.isFocusable()) element.setTabIndex(0);
+ else element.setTabIndex(-1);
+ if (node.getRole() == AccessibilityRole.STATIC_TEXT || node.getRole() == AccessibilityRole.HEADING) {
+ element.setTextContent(node.getLabel() == null ? "" : node.getLabel());
+ }
+ }
+
+ private void addWebCustomActions(HTMLElement parent, AccessibilityNodeSnapshot node) {
+ for (final AccessibilityAction action : node.getActions()) {
+ if (!action.isEnabled() || isStandardWebAction(action.getId())) continue;
+ final long nodeId = node.getId();
+ HTMLElement button = (HTMLElement)document.createElement("button");
+ button.setAttribute("type", "button");
+ button.setAttribute("aria-label", action.getLabel() == null ? action.getId() : action.getLabel());
+ button.setTextContent(action.getLabel() == null ? action.getId() : action.getLabel());
+ button.getStyle().setCssText("position:absolute;opacity:0.001;pointer-events:none;width:1px;height:1px;");
+ button.addEventListener("click", new EventListener() {
+ public void handleEvent(Event event) {
+ event.preventDefault();
+ performAccessibilityAction(nodeId, action.getId(), null);
+ }
+ });
+ parent.appendChild(button);
+ }
+ }
+
+ private boolean isStandardWebAction(String id) {
+ return AccessibilityAction.ACTIVATE.equals(id) || AccessibilityAction.FOCUS.equals(id)
+ || AccessibilityAction.INCREMENT.equals(id) || AccessibilityAction.DECREMENT.equals(id)
+ || AccessibilityAction.SET_TEXT.equals(id) || AccessibilityAction.SCROLL_FORWARD.equals(id)
+ || AccessibilityAction.SCROLL_BACKWARD.equals(id);
+ }
+
+ private String ariaRole(AccessibilityRole role) {
+ switch (role) {
+ case BUTTON:
+ case TOGGLE_BUTTON: return "button";
+ case CHECKBOX: return "checkbox";
+ case RADIO_BUTTON: return "radio";
+ case SWITCH: return "switch";
+ case HEADING: return "heading";
+ case LINK: return "link";
+ case IMAGE: return "img";
+ case TEXT_FIELD: return "textbox";
+ case SEARCH_FIELD: return "searchbox";
+ case SLIDER: return "slider";
+ case PROGRESS_BAR: return "progressbar";
+ case LIST: return "list";
+ case LIST_ITEM: return "listitem";
+ case GRID: return "grid";
+ case ROW: return "row";
+ case CELL: return "gridcell";
+ case COLUMN_HEADER: return "columnheader";
+ case ROW_HEADER: return "rowheader";
+ case TAB_LIST: return "tablist";
+ case TAB: return "tab";
+ case TAB_PANEL: return "tabpanel";
+ case DIALOG: return "dialog";
+ case ALERT: return "alert";
+ case MENU: return "menu";
+ case MENU_ITEM: return "menuitem";
+ case TOOLBAR: return "toolbar";
+ case SCROLL_BAR: return "scrollbar";
+ case SPIN_BUTTON: return "spinbutton";
+ case COMBO_BOX: return "combobox";
+ case TREE: return "tree";
+ case TREE_ITEM: return "treeitem";
+ case SEPARATOR: return "separator";
+ case GENERIC: return "group";
+ default: return null;
+ }
+ }
public static void setMainClass(Object main) {
JavaScriptBootstrapCoordinator.bindMainClass(main,
@@ -10609,6 +10811,35 @@ public boolean requiresHeavyButtonForCopyToClipboard() {
public Boolean isDarkMode() {
return isDarkMode_();
}
+
+ @JSBody(params={"query"}, script="return !!(window.matchMedia && window.matchMedia(query).matches);")
+ private static native boolean matchesMediaQuery(String query);
+
+ @Override
+ public boolean isHighContrastEnabled() {
+ return matchesMediaQuery("(forced-colors: active)")
+ || matchesMediaQuery("(prefers-contrast: more)");
+ }
+
+ @Override
+ public boolean isDifferentiateWithoutColorEnabled() {
+ return matchesMediaQuery("(forced-colors: active)");
+ }
+
+ @Override
+ public AccessibilityColorVisionDeficiency getColorVisionDeficiency() {
+ return AccessibilityColorVisionDeficiency.UNKNOWN;
+ }
+
+ @Override
+ public boolean isReduceMotionEnabled() {
+ return matchesMediaQuery("(prefers-reduced-motion: reduce)");
+ }
+
+ @Override
+ public boolean isReduceTransparencyEnabled() {
+ return matchesMediaQuery("(prefers-reduced-transparency: reduce)");
+ }
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h
index 97aa5f4a8a7..7a1de5a5268 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux.h
+++ b/Ports/LinuxPort/nativeSources/cn1_linux.h
@@ -69,7 +69,8 @@ typedef enum {
* ROTATE. drainInput decodes these into Display.fireMagnifyGesture /
* fireRotationGesture, the same hooks the macOS trackpad drives. */
CN1_EVENT_PINCH = 10,
- CN1_EVENT_ROTATE = 11
+ CN1_EVENT_ROTATE = 11,
+ CN1_EVENT_ACCESSIBILITY_ACTION = 12
} CN1EventType;
/* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */
diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c
index 8e5d032e3bd..198c129118e 100644
--- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c
+++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c
@@ -95,6 +95,7 @@ static GtkWidget* cn1Window = 0;
static GtkWidget* cn1DrawingArea = 0;
static GtkWidget* cn1Overlay = 0; /* GtkOverlay: drawing area + native widget layer */
static GtkWidget* cn1Fixed = 0; /* GtkFixed overlay hosting positioned native peers */
+static GtkWidget* cn1AccessibilityFixed = 0; /* transparent GTK/ATK semantic hierarchy */
static CN1Graphics cn1WindowG; /* the on-screen / headless back buffer */
static int cn1DisplayWidth = 800;
static int cn1DisplayHeight = 600;
@@ -602,6 +603,10 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in
cn1Fixed = gtk_fixed_new();
gtk_overlay_add_overlay(GTK_OVERLAY(cn1Overlay), cn1Fixed);
gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(cn1Overlay), cn1Fixed, TRUE);
+ cn1AccessibilityFixed = gtk_fixed_new();
+ gtk_widget_set_opacity(cn1AccessibilityFixed, 0.01);
+ gtk_overlay_add_overlay(GTK_OVERLAY(cn1Overlay), cn1AccessibilityFixed);
+ gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(cn1Overlay), cn1AccessibilityFixed, TRUE);
gtk_container_add(GTK_CONTAINER(cn1Window), cn1Overlay);
g_signal_connect(cn1DrawingArea, "draw", G_CALLBACK(cn1OnDraw), 0);
@@ -643,6 +648,25 @@ JAVA_INT com_codename1_impl_linux_LinuxNative_screenDpi___R_int(CODENAME_ONE_THR
return 96;
}
+JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_isHighContrastEnabled___R_boolean(CODENAME_ONE_THREAD_STATE) {
+ const char* theme = getenv("GTK_THEME");
+ return theme != NULL && strcasestr(theme, "highcontrast") != NULL ? JAVA_TRUE : JAVA_FALSE;
+}
+
+JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_isReduceMotionEnabled___R_boolean(CODENAME_ONE_THREAD_STATE) {
+ const char* animations = getenv("GTK_ENABLE_ANIMATIONS");
+ return animations != NULL && strcmp(animations, "0") == 0 ? JAVA_TRUE : JAVA_FALSE;
+}
+
+JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_isScreenReaderEnabled___R_boolean(CODENAME_ONE_THREAD_STATE) {
+ const char* modules = getenv("GTK_MODULES");
+ if (modules == NULL) {
+ return JAVA_FALSE;
+ }
+ return strcasestr(modules, "atk-bridge") != NULL || strcasestr(modules, "gail") != NULL
+ ? JAVA_TRUE : JAVA_FALSE;
+}
+
/* True when the default seat has a touchscreen pointing device attached, so the
* framework reports a touch device (Display.isTouchScreen()). */
JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_isTouchDevice___R_boolean(CODENAME_ONE_THREAD_STATE) {
@@ -714,6 +738,223 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_pollEvent___int_1ARRAY_R_boole
return JAVA_FALSE;
}
+/* ------------------------------------------------ accessibility / AT-SPI */
+
+typedef struct CN1A11yEntry {
+ long long id;
+ long long parentId;
+ GtkWidget* widget;
+ int x, y;
+ struct CN1A11yEntry* next;
+} CN1A11yEntry;
+
+typedef struct {
+ long long nodeId;
+ int actionHash;
+ char* actionId;
+ char* label;
+} CN1A11yAction;
+
+static CN1A11yEntry* cn1A11yEntries = 0;
+
+static void cn1A11yFreeEntries(void) {
+ CN1A11yEntry* entry = cn1A11yEntries;
+ while (entry) {
+ CN1A11yEntry* next = entry->next;
+ free(entry);
+ entry = next;
+ }
+ cn1A11yEntries = 0;
+}
+
+static CN1A11yEntry* cn1A11yFind(long long id) {
+ CN1A11yEntry* entry;
+ for (entry = cn1A11yEntries; entry; entry = entry->next) {
+ if (entry->id == id) return entry;
+ }
+ return 0;
+}
+
+static AtkRole cn1A11yRole(const char* role) {
+ if (!role) return ATK_ROLE_PANEL;
+ if (!strcmp(role, "BUTTON") || !strcmp(role, "TOGGLE_BUTTON")) return ATK_ROLE_PUSH_BUTTON;
+ if (!strcmp(role, "CHECKBOX")) return ATK_ROLE_CHECK_BOX;
+ if (!strcmp(role, "RADIO_BUTTON")) return ATK_ROLE_RADIO_BUTTON;
+ if (!strcmp(role, "SWITCH")) return ATK_ROLE_TOGGLE_BUTTON;
+ if (!strcmp(role, "HEADING")) return ATK_ROLE_HEADING;
+ if (!strcmp(role, "LINK")) return ATK_ROLE_LINK;
+ if (!strcmp(role, "IMAGE")) return ATK_ROLE_IMAGE;
+ if (!strcmp(role, "STATIC_TEXT")) return ATK_ROLE_LABEL;
+ if (!strcmp(role, "TEXT_FIELD") || !strcmp(role, "SEARCH_FIELD")) return ATK_ROLE_ENTRY;
+ if (!strcmp(role, "SLIDER")) return ATK_ROLE_SLIDER;
+ if (!strcmp(role, "PROGRESS_BAR")) return ATK_ROLE_PROGRESS_BAR;
+ if (!strcmp(role, "LIST")) return ATK_ROLE_LIST;
+ if (!strcmp(role, "LIST_ITEM")) return ATK_ROLE_LIST_ITEM;
+ if (!strcmp(role, "GRID")) return ATK_ROLE_TABLE;
+ if (!strcmp(role, "ROW")) return ATK_ROLE_TABLE_ROW;
+ if (!strcmp(role, "CELL")) return ATK_ROLE_TABLE_CELL;
+ if (!strcmp(role, "COLUMN_HEADER")) return ATK_ROLE_COLUMN_HEADER;
+ if (!strcmp(role, "ROW_HEADER")) return ATK_ROLE_ROW_HEADER;
+ if (!strcmp(role, "TAB_LIST")) return ATK_ROLE_PAGE_TAB_LIST;
+ if (!strcmp(role, "TAB")) return ATK_ROLE_PAGE_TAB;
+ if (!strcmp(role, "DIALOG")) return ATK_ROLE_DIALOG;
+ if (!strcmp(role, "ALERT")) return ATK_ROLE_ALERT;
+ if (!strcmp(role, "MENU")) return ATK_ROLE_MENU;
+ if (!strcmp(role, "MENU_ITEM")) return ATK_ROLE_MENU_ITEM;
+ if (!strcmp(role, "TOOLBAR")) return ATK_ROLE_TOOL_BAR;
+ if (!strcmp(role, "SCROLL_BAR")) return ATK_ROLE_SCROLL_BAR;
+ if (!strcmp(role, "COMBO_BOX")) return ATK_ROLE_COMBO_BOX;
+ if (!strcmp(role, "TREE")) return ATK_ROLE_TREE;
+ if (!strcmp(role, "TREE_ITEM")) return ATK_ROLE_TREE_ITEM;
+ if (!strcmp(role, "SEPARATOR")) return ATK_ROLE_SEPARATOR;
+ return ATK_ROLE_PANEL;
+}
+
+static void cn1A11yBeginMain(void* ignored) {
+ (void) ignored;
+ cn1A11yFreeEntries();
+ if (cn1AccessibilityFixed) {
+ GList* children = gtk_container_get_children(GTK_CONTAINER(cn1AccessibilityFixed));
+ GList* item;
+ for (item = children; item; item = item->next) {
+ gtk_widget_destroy(GTK_WIDGET(item->data));
+ }
+ g_list_free(children);
+ }
+}
+
+JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityBegin__(CODENAME_ONE_THREAD_STATE) {
+ cn1LinuxRunOnMainAndWait(cn1A11yBeginMain, 0);
+}
+
+typedef struct {
+ long long id, parentId;
+ char *role, *label, *description, *value;
+ int x, y, width, height, flags;
+} CN1A11yNodeCall;
+
+static GtkWidget* cn1A11yWidget(const char* role, const char* label, const char* value, int flags) {
+ GtkWidget* widget;
+ if (role && (!strcmp(role, "BUTTON") || !strcmp(role, "TOGGLE_BUTTON") || !strcmp(role, "SWITCH"))) {
+ widget = gtk_toggle_button_new_with_label(label ? label : "");
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(widget), (flags & 0x30) == 0x20);
+ gtk_toggle_button_set_inconsistent(GTK_TOGGLE_BUTTON(widget), (flags & 0x30) == 0x30);
+ } else if (role && (!strcmp(role, "CHECKBOX") || !strcmp(role, "RADIO_BUTTON"))) {
+ widget = gtk_check_button_new_with_label(label ? label : "");
+ gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(widget), (flags & 0x30) == 0x20);
+ gtk_toggle_button_set_inconsistent(GTK_TOGGLE_BUTTON(widget), (flags & 0x30) == 0x30);
+ } else if (role && (!strcmp(role, "TEXT_FIELD") || !strcmp(role, "SEARCH_FIELD"))) {
+ widget = gtk_entry_new();
+ if (value) gtk_entry_set_text(GTK_ENTRY(widget), value);
+ } else if (role && (!strcmp(role, "SLIDER") || !strcmp(role, "PROGRESS_BAR"))) {
+ widget = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL, 0, 100, 1);
+ if (value) gtk_range_set_value(GTK_RANGE(widget), atof(value));
+ } else {
+ widget = gtk_fixed_new();
+ }
+ return widget;
+}
+
+static void cn1A11yNodeMain(void* pointer) {
+ CN1A11yNodeCall* call = (CN1A11yNodeCall*) pointer;
+ CN1A11yEntry* parent = cn1A11yFind(call->parentId);
+ GtkWidget* host = parent ? parent->widget : cn1AccessibilityFixed;
+ GtkWidget* widget = cn1A11yWidget(call->role, call->label, call->value, call->flags);
+ AtkObject* accessible = gtk_widget_get_accessible(widget);
+ atk_object_set_role(accessible, cn1A11yRole(call->role));
+ if (call->label) atk_object_set_name(accessible, call->label);
+ if (call->description) atk_object_set_description(accessible, call->description);
+ gtk_widget_set_can_focus(widget, (call->flags & 1) != 0);
+ gtk_widget_set_sensitive(widget, (call->flags & 4) != 0);
+ gtk_widget_set_size_request(widget, call->width > 0 ? call->width : 1,
+ call->height > 0 ? call->height : 1);
+ if (host && GTK_IS_FIXED(host)) {
+ int px = parent ? call->x - parent->x : call->x;
+ int py = parent ? call->y - parent->y : call->y;
+ gtk_fixed_put(GTK_FIXED(host), widget, px, py);
+ } else if (host && GTK_IS_CONTAINER(host)) {
+ gtk_container_add(GTK_CONTAINER(host), widget);
+ }
+ gtk_widget_show_all(widget);
+ CN1A11yEntry* entry = (CN1A11yEntry*) calloc(1, sizeof(CN1A11yEntry));
+ entry->id = call->id; entry->parentId = call->parentId; entry->widget = widget;
+ entry->x = call->x; entry->y = call->y; entry->next = cn1A11yEntries; cn1A11yEntries = entry;
+ free(call->role); free(call->label); free(call->description); free(call->value); free(call);
+}
+
+JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityNode___long_long_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_int_int_int_int(
+ CODENAME_ONE_THREAD_STATE, JAVA_LONG id, JAVA_LONG parentId, JAVA_OBJECT role, JAVA_OBJECT label,
+ JAVA_OBJECT description, JAVA_OBJECT value, JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height,
+ JAVA_INT flags) {
+ extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT);
+ CN1A11yNodeCall* call = (CN1A11yNodeCall*) calloc(1, sizeof(CN1A11yNodeCall));
+ call->id = id; call->parentId = parentId; call->x = x; call->y = y;
+ call->width = width; call->height = height; call->flags = flags;
+ call->role = role == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, role));
+ call->label = label == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, label));
+ call->description = description == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, description));
+ call->value = value == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, value));
+ cn1LinuxRunOnMainAndWait(cn1A11yNodeMain, call);
+}
+
+static void cn1A11yActionActivated(GtkWidget* widget, gpointer pointer) {
+ CN1A11yAction* action = (CN1A11yAction*) pointer;
+ (void) widget;
+ cn1LinuxPushEvent(CN1_EVENT_ACCESSIBILITY_ACTION, (int) action->nodeId, 0, action->actionHash);
+}
+
+static void cn1A11yActionFree(gpointer pointer, GClosure* closure) {
+ CN1A11yAction* action = (CN1A11yAction*) pointer;
+ (void) closure;
+ free(action->actionId); free(action->label); free(action);
+}
+
+static void cn1A11yActionMain(void* pointer) {
+ CN1A11yAction* action = (CN1A11yAction*) pointer;
+ CN1A11yEntry* entry = cn1A11yFind(action->nodeId);
+ if (!entry) { cn1A11yActionFree(action, 0); return; }
+ if (!strcmp(action->actionId, "activate") || !strcmp(action->actionId, "focus")) {
+ if (GTK_IS_BUTTON(entry->widget)) {
+ g_signal_connect_data(entry->widget, "clicked", G_CALLBACK(cn1A11yActionActivated), action,
+ cn1A11yActionFree, 0);
+ return;
+ }
+ } else {
+ GtkWidget* custom = gtk_button_new_with_label(action->label ? action->label : action->actionId);
+ AtkObject* accessible = gtk_widget_get_accessible(custom);
+ atk_object_set_name(accessible, action->label ? action->label : action->actionId);
+ atk_object_set_description(accessible, "Custom accessibility action");
+ gtk_widget_set_size_request(custom, entry->widget ? gtk_widget_get_allocated_width(entry->widget) : 1,
+ entry->widget ? gtk_widget_get_allocated_height(entry->widget) : 1);
+ gtk_fixed_put(GTK_FIXED(cn1AccessibilityFixed), custom, entry->x, entry->y);
+ gtk_widget_show(custom);
+ g_signal_connect_data(custom, "clicked", G_CALLBACK(cn1A11yActionActivated), action,
+ cn1A11yActionFree, 0);
+ return;
+ }
+ cn1A11yActionFree(action, 0);
+}
+
+JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityAction___long_java_lang_String_int_java_lang_String(
+ CODENAME_ONE_THREAD_STATE, JAVA_LONG nodeId, JAVA_OBJECT actionId, JAVA_INT actionHash, JAVA_OBJECT label) {
+ extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT);
+ CN1A11yAction* action = (CN1A11yAction*) calloc(1, sizeof(CN1A11yAction));
+ action->nodeId = nodeId; action->actionHash = actionHash;
+ action->actionId = actionId == JAVA_NULL ? strdup("") : strdup(stringToUTF8(threadStateData, actionId));
+ action->label = label == JAVA_NULL ? 0 : strdup(stringToUTF8(threadStateData, label));
+ cn1LinuxRunOnMainAndWait(cn1A11yActionMain, action);
+}
+
+static void cn1A11yEndMain(void* pointer) {
+ (void) pointer;
+ if (cn1AccessibilityFixed) gtk_widget_show_all(cn1AccessibilityFixed);
+}
+
+JAVA_VOID com_codename1_impl_linux_LinuxNative_accessibilityEnd___int(CODENAME_ONE_THREAD_STATE, JAVA_INT changeType) {
+ (void) changeType;
+ cn1LinuxRunOnMainAndWait(cn1A11yEndMain, 0);
+}
+
JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_pumpMessages___R_boolean(CODENAME_ONE_THREAD_STATE) {
/* Process all pending GTK events, then block briefly for the next one so the
* loop yields the CPU. Returns false once the window has been closed. */
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
index 0ad7ab4eda3..cbef45d9a84 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java
@@ -35,6 +35,9 @@
import com.codename1.ui.Stroke;
import com.codename1.ui.geom.PathIterator;
import com.codename1.ui.geom.Shape;
+import com.codename1.ui.accessibility.AccessibilityAction;
+import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ByteArrayInputStream;
@@ -42,7 +45,12 @@
import java.io.OutputStream;
import java.util.Calendar;
import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
/**
* Native Linux (GTK3, desktop) implementation of the Codename One platform
@@ -66,6 +74,21 @@
* headless (see {@code Ports/LinuxPort/status.md}).
*/
public class LinuxImplementation extends CodenameOneImplementation {
+
+ @Override
+ public boolean isHighContrastEnabled() {
+ return LinuxNative.isHighContrastEnabled();
+ }
+
+ @Override
+ public boolean isReduceMotionEnabled() {
+ return LinuxNative.isReduceMotionEnabled();
+ }
+
+ @Override
+ public boolean isScreenReaderEnabled() {
+ return LinuxNative.isScreenReaderEnabled();
+ }
private static LinuxImplementation INSTANCE;
// Event type codes; must mirror the CN1EventType enum in cn1_linux.h.
@@ -80,6 +103,7 @@ public class LinuxImplementation extends CodenameOneImplementation {
private static final int EVENT_MOUSE_HWHEEL = 9;
private static final int EVENT_PINCH = 10;
private static final int EVENT_ROTATE = 11;
+ private static final int EVENT_ACCESSIBILITY_ACTION = 12;
// The native gesture events encode their float (incremental scale / radians) as
// an int in 1/10000 units; see CN1_GESTURE_FIXED in cn1_linux.h.
@@ -97,6 +121,10 @@ public class LinuxImplementation extends CodenameOneImplementation {
private L10NManager l10n;
private com.codename1.ui.util.ImageIO imageIO;
private final int[] eventScratch = new int[4];
+ private final Map accessibilityActionTokens = new HashMap();
+ private final Map accessibilityActionTargets =
+ new HashMap();
+ private int nextAccessibilityActionToken = 1;
/**
* Registers the singleton so the Win32 native bootstrap and message loop can
@@ -681,6 +709,12 @@ private void drainInput() {
case EVENT_CLOSE:
Display.getInstance().exitApplication();
break;
+ case EVENT_ACCESSIBILITY_ACTION:
+ AccessibilityActionTarget actionTarget = accessibilityActionTargets.get(Integer.valueOf(key));
+ if (actionTarget != null) {
+ performAccessibilityAction(actionTarget.nodeId, actionTarget.actionId, null);
+ }
+ break;
default:
break;
}
@@ -693,6 +727,97 @@ private void drainInput() {
}
}
+ @Override
+ public void accessibilityTreeChanged(int changeType) {
+ AccessibilityTreeSnapshot tree = getAccessibilityTreeSnapshot();
+ Set liveActionKeys = new HashSet();
+ LinuxNative.accessibilityBegin();
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ int flags = (node.isFocusable() ? 1 : 0) | (node.isFocused() ? 2 : 0)
+ | (Boolean.TRUE.equals(node.getEnabled()) ? 4 : 0)
+ | (Boolean.TRUE.equals(node.getSelected()) ? 8 : 0)
+ | (node.getChecked().ordinal() << 4)
+ | (Boolean.TRUE.equals(node.getExpanded()) ? 64 : 0)
+ | (Boolean.TRUE.equals(node.getInvalid()) ? 128 : 0);
+ com.codename1.ui.geom.Rectangle b = node.getBounds();
+ LinuxNative.accessibilityNode(node.getId(), node.getParentId(), node.getRole().name(), node.getLabel(),
+ joinDescription(node), node.getValue(), b.getX(), b.getY(), b.getWidth(), b.getHeight(), flags);
+ for (AccessibilityAction action : node.getActions()) {
+ if (action.isEnabled()) {
+ String actionKey = accessibilityActionKey(node.getId(), action.getId());
+ liveActionKeys.add(actionKey);
+ LinuxNative.accessibilityAction(node.getId(), action.getId(),
+ accessibilityActionToken(node.getId(), action.getId()),
+ action.getLabel() == null ? action.getId() : action.getLabel());
+ }
+ }
+ }
+ LinuxNative.accessibilityEnd(changeType);
+ Iterator> iterator = accessibilityActionTokens.entrySet().iterator();
+ while (iterator.hasNext()) {
+ Map.Entry entry = iterator.next();
+ if (!liveActionKeys.contains(entry.getKey())) {
+ accessibilityActionTargets.remove(entry.getValue());
+ iterator.remove();
+ }
+ }
+ }
+
+ private int accessibilityActionToken(long nodeId, String actionId) {
+ String key = accessibilityActionKey(nodeId, actionId);
+ Integer existing = accessibilityActionTokens.get(key);
+ if (existing != null) {
+ accessibilityActionTargets.put(existing, new AccessibilityActionTarget(nodeId, actionId));
+ return existing.intValue();
+ }
+ int candidate = nextAccessibilityActionToken;
+ while (accessibilityActionTargets.containsKey(Integer.valueOf(candidate))) {
+ candidate = nextAccessibilityActionToken(candidate);
+ }
+ nextAccessibilityActionToken = nextAccessibilityActionToken(candidate);
+ Integer token = Integer.valueOf(candidate);
+ accessibilityActionTokens.put(key, token);
+ accessibilityActionTargets.put(token, new AccessibilityActionTarget(nodeId, actionId));
+ return candidate;
+ }
+
+ private int nextAccessibilityActionToken(int current) {
+ return current == Integer.MAX_VALUE ? 1 : current + 1;
+ }
+
+ private String accessibilityActionKey(long nodeId, String actionId) {
+ return nodeId + "\n" + actionId;
+ }
+
+ private static final class AccessibilityActionTarget {
+ final long nodeId;
+ final String actionId;
+
+ AccessibilityActionTarget(long nodeId, String actionId) {
+ this.nodeId = nodeId;
+ this.actionId = actionId;
+ }
+ }
+
+ private String joinDescription(AccessibilityNodeSnapshot node) {
+ StringBuilder out = new StringBuilder();
+ if (node.getHint() != null) out.append(node.getHint());
+ if (node.getDescription() != null) {
+ if (out.length() > 0) out.append(". ");
+ out.append(node.getDescription());
+ }
+ if (node.getValidationError() != null) {
+ if (out.length() > 0) out.append(". ");
+ out.append(node.getValidationError());
+ }
+ return out.length() == 0 ? null : out.toString();
+ }
+
+ @Override
+ public boolean isAccessibilityTreeSupported() {
+ return true;
+ }
+
/* --------------------------------------------------- local notifications
* Desktop semantic (mirrors the JavaSE port): while the app runs, a Timer
* fires the notification at its scheduled time and Shell_NotifyIcon shows a
diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java
index dd4d719d858..95c5e4f36c2 100644
--- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java
+++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java
@@ -125,6 +125,15 @@ public static native long videoWriterOpen(String outPath, boolean hevc, int widt
/** Real horizontal screen DPI (96 == 100% scale). */
public static native int screenDpi();
+ /** True when the GTK theme requests high contrast. */
+ public static native boolean isHighContrastEnabled();
+
+ /** True when GTK animations are disabled. */
+ public static native boolean isReduceMotionEnabled();
+
+ /** True when the GTK accessibility bridge is active. */
+ public static native boolean isScreenReaderEnabled();
+
/** The window's graphics peer (a Direct2D render target wrapper). */
public static native long getWindowGraphics();
@@ -145,6 +154,13 @@ public static native void setTransform(long graphics, float m00, float m10, floa
*/
public static native boolean pollEvent(int[] out);
+ /** Rebuilds the GTK/ATK virtual accessibility hierarchy. */
+ public static native void accessibilityBegin();
+ public static native void accessibilityNode(long id, long parentId, String role, String label,
+ String description, String value, int x, int y, int width, int height, int stateFlags);
+ public static native void accessibilityAction(long nodeId, String actionId, int actionHash, String label);
+ public static native void accessibilityEnd(int changeType);
+
/**
* True when the default seat has a touchscreen device, used for
* {@code Display.isTouchScreen()}.
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h
index f044e2a0611..b576c550cc7 100644
--- a/Ports/WindowsPort/nativeSources/cn1_windows.h
+++ b/Ports/WindowsPort/nativeSources/cn1_windows.h
@@ -101,7 +101,8 @@ typedef enum {
* ROTATE. drainInput decodes these into Display.fireMagnifyGesture /
* fireRotationGesture, the same hooks the macOS trackpad drives. */
CN1_EVENT_PINCH = 10,
- CN1_EVENT_ROTATE = 11
+ CN1_EVENT_ROTATE = 11,
+ CN1_EVENT_ACCESSIBILITY_ACTION = 12
} CN1EventType;
/* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */
@@ -255,6 +256,7 @@ int cn1WinCreateWindow(const char* utf8Title, int width, int height);
void cn1WinPushEvent(CN1EventType type, int x, int y, int keyCode);
int cn1WinPollEvent(CN1Event* out);
LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
+LRESULT cn1WinAccessibilityObject(HWND hwnd, WPARAM wParam, LPARAM lParam);
/* BrowserComponent / WebView2 peer (cn1_windows_browser.cpp). The EDT-facing
* native methods marshal each WebView2 operation to the main (pump) thread by
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_accessibility.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_accessibility.cpp
new file mode 100644
index 00000000000..4dd96c2f93a
--- /dev/null
+++ b/Ports/WindowsPort/nativeSources/cn1_windows_accessibility.cpp
@@ -0,0 +1,296 @@
+/*
+ * 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.
+ */
+
+/* Native virtual accessibility tree for the lightweight Win32 port. */
+#ifdef _WIN32
+
+/* xwin may provide a newer MSVC STL than the clang-cl installed by the Linux
+ * cross-compile runner. This file uses only the long-stable string/vector
+ * subset, so allow that supported cross-toolchain pairing. */
+#ifndef _ALLOW_COMPILER_AND_STL_VERSION_MISMATCH
+#define _ALLOW_COMPILER_AND_STL_VERSION_MISMATCH
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include "cn1_windows.h"
+
+#pragma comment(lib, "uiautomationcore.lib")
+#pragma comment(lib, "oleaut32.lib")
+
+struct CN1UiaNode {
+ long long id, parent;
+ long long actionTarget;
+ std::wstring role, label, description, value;
+ int x, y, width, height, flags;
+ std::vector > actions;
+};
+
+static SRWLOCK cn1UiaLock = SRWLOCK_INIT;
+static std::vector cn1UiaNodes;
+static std::vector cn1UiaPending;
+
+static std::wstring cn1UiaWide(const char* value) {
+ if (!value) return std::wstring();
+ int length = MultiByteToWideChar(CP_UTF8, 0, value, -1, NULL, 0);
+ if (length <= 1) return std::wstring();
+ std::vector buffer((size_t) length);
+ MultiByteToWideChar(CP_UTF8, 0, value, -1, &buffer[0], length);
+ return std::wstring(&buffer[0]);
+}
+
+static bool cn1UiaGet(long long id, CN1UiaNode& out) {
+ bool found = false;
+ AcquireSRWLockShared(&cn1UiaLock);
+ for (size_t i = 0; i < cn1UiaNodes.size(); ++i) {
+ if (cn1UiaNodes[i].id == id) { out = cn1UiaNodes[i]; found = true; break; }
+ }
+ ReleaseSRWLockShared(&cn1UiaLock);
+ return found;
+}
+
+static long long cn1UiaNavigate(long long id, NavigateDirection direction) {
+ long long result = 0;
+ AcquireSRWLockShared(&cn1UiaLock);
+ if (id == 0) {
+ if (direction == NavigateDirection_FirstChild || direction == NavigateDirection_LastChild) {
+ for (size_t i = 0; i < cn1UiaNodes.size(); ++i) {
+ size_t index = direction == NavigateDirection_FirstChild ? i : cn1UiaNodes.size() - 1 - i;
+ if (cn1UiaNodes[index].parent < 0) { result = cn1UiaNodes[index].id; break; }
+ }
+ }
+ } else {
+ size_t current = cn1UiaNodes.size();
+ for (size_t i = 0; i < cn1UiaNodes.size(); ++i) if (cn1UiaNodes[i].id == id) { current = i; break; }
+ if (current < cn1UiaNodes.size()) {
+ const CN1UiaNode& node = cn1UiaNodes[current];
+ if (direction == NavigateDirection_Parent) result = node.parent < 0 ? 0 : node.parent;
+ if (direction == NavigateDirection_FirstChild || direction == NavigateDirection_LastChild) {
+ for (size_t i = 0; i < cn1UiaNodes.size(); ++i) {
+ size_t index = direction == NavigateDirection_FirstChild ? i : cn1UiaNodes.size() - 1 - i;
+ if (cn1UiaNodes[index].parent == id) { result = cn1UiaNodes[index].id; break; }
+ }
+ }
+ if (direction == NavigateDirection_NextSibling) {
+ for (size_t i = current + 1; i < cn1UiaNodes.size(); ++i)
+ if (cn1UiaNodes[i].parent == node.parent) { result = cn1UiaNodes[i].id; break; }
+ }
+ if (direction == NavigateDirection_PreviousSibling && current > 0) {
+ for (size_t i = current; i-- > 0; )
+ if (cn1UiaNodes[i].parent == node.parent) { result = cn1UiaNodes[i].id; break; }
+ }
+ }
+ }
+ ReleaseSRWLockShared(&cn1UiaLock);
+ return result;
+}
+
+static CONTROLTYPEID cn1UiaControlType(const std::wstring& role) {
+ if (role == L"BUTTON" || role == L"TOGGLE_BUTTON") return UIA_ButtonControlTypeId;
+ if (role == L"CHECKBOX" || role == L"SWITCH") return UIA_CheckBoxControlTypeId;
+ if (role == L"RADIO_BUTTON") return UIA_RadioButtonControlTypeId;
+ if (role == L"TEXT_FIELD" || role == L"SEARCH_FIELD") return UIA_EditControlTypeId;
+ if (role == L"SLIDER") return UIA_SliderControlTypeId;
+ if (role == L"PROGRESS_BAR") return UIA_ProgressBarControlTypeId;
+ if (role == L"LIST") return UIA_ListControlTypeId;
+ if (role == L"LIST_ITEM") return UIA_ListItemControlTypeId;
+ if (role == L"GRID") return UIA_DataGridControlTypeId;
+ if (role == L"ROW" || role == L"CELL") return UIA_DataItemControlTypeId;
+ if (role == L"TAB_LIST") return UIA_TabControlTypeId;
+ if (role == L"TAB") return UIA_TabItemControlTypeId;
+ if (role == L"DIALOG" || role == L"ALERT") return UIA_WindowControlTypeId;
+ if (role == L"MENU") return UIA_MenuControlTypeId;
+ if (role == L"MENU_ITEM") return UIA_MenuItemControlTypeId;
+ if (role == L"TOOLBAR") return UIA_ToolBarControlTypeId;
+ if (role == L"SCROLL_BAR") return UIA_ScrollBarControlTypeId;
+ if (role == L"COMBO_BOX") return UIA_ComboBoxControlTypeId;
+ if (role == L"TREE") return UIA_TreeControlTypeId;
+ if (role == L"TREE_ITEM") return UIA_TreeItemControlTypeId;
+ if (role == L"IMAGE") return UIA_ImageControlTypeId;
+ if (role == L"LINK") return UIA_HyperlinkControlTypeId;
+ if (role == L"STATIC_TEXT" || role == L"HEADING") return UIA_TextControlTypeId;
+ if (role == L"SEPARATOR") return UIA_SeparatorControlTypeId;
+ return UIA_GroupControlTypeId;
+}
+
+class CN1UiaProvider : public IRawElementProviderSimple, public IRawElementProviderFragment,
+ public IRawElementProviderFragmentRoot, public IInvokeProvider {
+ LONG refs;
+ long long id;
+public:
+ explicit CN1UiaProvider(long long nodeId) : refs(1), id(nodeId) {}
+ HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void** object) {
+ if (!object) return E_INVALIDARG;
+ *object = NULL;
+ if (iid == __uuidof(IUnknown) || iid == __uuidof(IRawElementProviderSimple))
+ *object = static_cast(this);
+ else if (iid == __uuidof(IRawElementProviderFragment))
+ *object = static_cast(this);
+ else if (iid == __uuidof(IRawElementProviderFragmentRoot) && id == 0)
+ *object = static_cast(this);
+ else if (iid == __uuidof(IInvokeProvider))
+ *object = static_cast(this);
+ if (!*object) return E_NOINTERFACE;
+ AddRef(); return S_OK;
+ }
+ ULONG STDMETHODCALLTYPE AddRef() { return (ULONG) InterlockedIncrement(&refs); }
+ ULONG STDMETHODCALLTYPE Release() { ULONG value = (ULONG) InterlockedDecrement(&refs); if (!value) delete this; return value; }
+
+ HRESULT STDMETHODCALLTYPE get_ProviderOptions(ProviderOptions* options) {
+ if (!options) return E_INVALIDARG; *options = ProviderOptions_ServerSideProvider; return S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE GetPatternProvider(PATTERNID pattern, IUnknown** provider) {
+ if (!provider) return E_INVALIDARG; *provider = NULL;
+ CN1UiaNode node;
+ if (pattern == UIA_InvokePatternId && id != 0 && cn1UiaGet(id, node) && !node.actions.empty()) {
+ *provider = static_cast(this); AddRef();
+ }
+ return S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE GetPropertyValue(PROPERTYID property, VARIANT* value) {
+ if (!value) return E_INVALIDARG;
+ VariantInit(value);
+ if (id == 0) {
+ if (property == UIA_NamePropertyId) { value->vt = VT_BSTR; value->bstrVal = SysAllocString(L"Codename One"); }
+ else if (property == UIA_ControlTypePropertyId) { value->vt = VT_I4; value->lVal = UIA_WindowControlTypeId; }
+ else if (property == UIA_IsControlElementPropertyId || property == UIA_IsContentElementPropertyId) { value->vt = VT_BOOL; value->boolVal = VARIANT_TRUE; }
+ return S_OK;
+ }
+ CN1UiaNode node; if (!cn1UiaGet(id, node)) return UIA_E_ELEMENTNOTAVAILABLE;
+ if (property == UIA_NamePropertyId) { value->vt = VT_BSTR; value->bstrVal = SysAllocString(node.label.c_str()); }
+ else if (property == UIA_HelpTextPropertyId) { value->vt = VT_BSTR; value->bstrVal = SysAllocString(node.description.c_str()); }
+ else if (property == UIA_ValueValuePropertyId) { value->vt = VT_BSTR; value->bstrVal = SysAllocString(node.value.c_str()); }
+ else if (property == UIA_ControlTypePropertyId) { value->vt = VT_I4; value->lVal = cn1UiaControlType(node.role); }
+ else if (property == UIA_LocalizedControlTypePropertyId) { value->vt = VT_BSTR; value->bstrVal = SysAllocString(node.role.c_str()); }
+ else if (property == UIA_IsEnabledPropertyId) { value->vt = VT_BOOL; value->boolVal = (node.flags & 4) ? VARIANT_TRUE : VARIANT_FALSE; }
+ else if (property == UIA_HasKeyboardFocusPropertyId) { value->vt = VT_BOOL; value->boolVal = (node.flags & 2) ? VARIANT_TRUE : VARIANT_FALSE; }
+ else if (property == UIA_IsKeyboardFocusablePropertyId) { value->vt = VT_BOOL; value->boolVal = (node.flags & 1) ? VARIANT_TRUE : VARIANT_FALSE; }
+ else if (property == UIA_IsControlElementPropertyId || property == UIA_IsContentElementPropertyId) { value->vt = VT_BOOL; value->boolVal = VARIANT_TRUE; }
+ return S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE get_HostRawElementProvider(IRawElementProviderSimple** provider) {
+ if (!provider) return E_INVALIDARG; *provider = NULL;
+ return id == 0 && cn1Win.hwnd ? UiaHostProviderFromHwnd(cn1Win.hwnd, provider) : S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE Navigate(NavigateDirection direction, IRawElementProviderFragment** result) {
+ if (!result) return E_INVALIDARG; *result = NULL;
+ long long target = cn1UiaNavigate(id, direction);
+ if (target || (direction == NavigateDirection_Parent && id != 0)) *result = new CN1UiaProvider(target);
+ return S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE GetRuntimeId(SAFEARRAY** runtimeId) {
+ if (!runtimeId) return E_INVALIDARG; *runtimeId = NULL;
+ if (id == 0) return S_OK;
+ SAFEARRAY* array = SafeArrayCreateVector(VT_I4, 0, 3); if (!array) return E_OUTOFMEMORY;
+ LONG indexes[] = {0, 1, 2}; int values[] = {UiaAppendRuntimeId, (int)(id >> 32), (int)id};
+ for (int i = 0; i < 3; ++i) SafeArrayPutElement(array, &indexes[i], &values[i]);
+ *runtimeId = array; return S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE get_BoundingRectangle(UiaRect* rectangle) {
+ if (!rectangle) return E_INVALIDARG;
+ if (id == 0) { RECT r; GetWindowRect(cn1Win.hwnd, &r); rectangle->left=r.left; rectangle->top=r.top; rectangle->width=r.right-r.left; rectangle->height=r.bottom-r.top; return S_OK; }
+ CN1UiaNode node; if (!cn1UiaGet(id, node)) return UIA_E_ELEMENTNOTAVAILABLE;
+ POINT point = {node.x, node.y}; ClientToScreen(cn1Win.hwnd, &point);
+ rectangle->left=point.x; rectangle->top=point.y; rectangle->width=node.width; rectangle->height=node.height; return S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE GetEmbeddedFragmentRoots(SAFEARRAY** roots) { if (!roots) return E_INVALIDARG; *roots=NULL; return S_OK; }
+ HRESULT STDMETHODCALLTYPE SetFocus() { return S_OK; }
+ HRESULT STDMETHODCALLTYPE get_FragmentRoot(IRawElementProviderFragmentRoot** root) {
+ if (!root) return E_INVALIDARG; *root = new CN1UiaProvider(0); return S_OK;
+ }
+ HRESULT STDMETHODCALLTYPE ElementProviderFromPoint(double x, double y, IRawElementProviderFragment** result) {
+ if (!result) return E_INVALIDARG; *result=NULL; POINT p={(LONG)x,(LONG)y}; ScreenToClient(cn1Win.hwnd,&p);
+ long long found=0; AcquireSRWLockShared(&cn1UiaLock);
+ for (size_t i=0;i=n.x&&p.y>=n.y&&p.xRelease(); return result;
+}
+
+extern "C" {
+JAVA_VOID com_codename1_impl_windows_WindowsNative_accessibilityBegin__(CODENAME_ONE_THREAD_STATE) {
+ AcquireSRWLockExclusive(&cn1UiaLock); cn1UiaPending.clear(); ReleaseSRWLockExclusive(&cn1UiaLock);
+}
+JAVA_VOID com_codename1_impl_windows_WindowsNative_accessibilityNode___long_long_java_lang_String_java_lang_String_java_lang_String_java_lang_String_int_int_int_int_int(
+ CODENAME_ONE_THREAD_STATE, JAVA_LONG id, JAVA_LONG parent, JAVA_OBJECT role, JAVA_OBJECT label,
+ JAVA_OBJECT description, JAVA_OBJECT value, JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height, JAVA_INT flags) {
+ extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT);
+ CN1UiaNode node; node.id=id; node.parent=parent; node.actionTarget=id; node.x=x; node.y=y; node.width=width; node.height=height; node.flags=flags;
+ node.role=role==JAVA_NULL?L"":cn1UiaWide(stringToUTF8(threadStateData,role));
+ node.label=label==JAVA_NULL?L"":cn1UiaWide(stringToUTF8(threadStateData,label));
+ node.description=description==JAVA_NULL?L"":cn1UiaWide(stringToUTF8(threadStateData,description));
+ node.value=value==JAVA_NULL?L"":cn1UiaWide(stringToUTF8(threadStateData,value));
+ AcquireSRWLockExclusive(&cn1UiaLock); cn1UiaPending.push_back(node); ReleaseSRWLockExclusive(&cn1UiaLock);
+}
+JAVA_VOID com_codename1_impl_windows_WindowsNative_accessibilityAction___long_java_lang_String_int_java_lang_String(
+ CODENAME_ONE_THREAD_STATE, JAVA_LONG nodeId, JAVA_OBJECT actionId, JAVA_INT actionHash, JAVA_OBJECT label) {
+ extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT);
+ std::wstring idText=actionId==JAVA_NULL?L"":cn1UiaWide(stringToUTF8(threadStateData,actionId));
+ std::wstring text=label==JAVA_NULL?L"":cn1UiaWide(stringToUTF8(threadStateData,label));
+ AcquireSRWLockExclusive(&cn1UiaLock);
+ for(size_t i=0;iRelease();
+}
+}
+
+#endif
diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp
index a474397b938..3c851592b15 100644
--- a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp
+++ b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp
@@ -471,6 +471,8 @@ static int cn1WinHandleGesture(HWND hwnd, LPARAM lParam) {
LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
switch (msg) {
+ case WM_GETOBJECT:
+ return cn1WinAccessibilityObject(hwnd, wParam, lParam);
case WM_LBUTTONDOWN:
cn1WinButtonDown(hwnd, CN1_PE_MASK_PRIMARY);
cn1WinPushEvent(CN1_EVENT_POINTER_PRESSED, GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam),
@@ -865,6 +867,29 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_isTouchDevice___R_boolean(
return JAVA_FALSE;
}
+JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_isHighContrastEnabled___R_boolean(CODENAME_ONE_THREAD_STATE) {
+ HIGHCONTRASTW contrast = {};
+ contrast.cbSize = sizeof(contrast);
+ if (!SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(contrast), &contrast, 0)) return JAVA_FALSE;
+ return (contrast.dwFlags & HCF_HIGHCONTRASTON) != 0 ? JAVA_TRUE : JAVA_FALSE;
+}
+
+JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_isReduceMotionEnabled___R_boolean(CODENAME_ONE_THREAD_STATE) {
+#ifdef SPI_GETCLIENTAREAANIMATION
+ BOOL animationsEnabled = TRUE;
+ if (!SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION, 0, &animationsEnabled, 0)) return JAVA_FALSE;
+ return animationsEnabled ? JAVA_FALSE : JAVA_TRUE;
+#else
+ return JAVA_FALSE;
+#endif
+}
+
+JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_isScreenReaderEnabled___R_boolean(CODENAME_ONE_THREAD_STATE) {
+ BOOL screenReader = FALSE;
+ if (!SystemParametersInfoW(SPI_GETSCREENREADER, 0, &screenReader, 0)) return JAVA_FALSE;
+ return screenReader ? JAVA_TRUE : JAVA_FALSE;
+}
+
JAVA_INT com_codename1_impl_windows_WindowsNative_getDisplayHeight___R_int(CODENAME_ONE_THREAD_STATE) {
return cn1Win.height;
}
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
index 11e73024e38..caf155660eb 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java
@@ -35,6 +35,9 @@
import com.codename1.ui.Stroke;
import com.codename1.ui.geom.PathIterator;
import com.codename1.ui.geom.Shape;
+import com.codename1.ui.accessibility.AccessibilityAction;
+import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ByteArrayInputStream;
@@ -42,7 +45,12 @@
import java.io.OutputStream;
import java.util.Calendar;
import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
/**
* Native Windows (Win32, desktop / tablet) implementation of the Codename One
@@ -57,6 +65,21 @@
* minimal in this first cut and grow in later phases.
*/
public class WindowsImplementation extends CodenameOneImplementation {
+
+ @Override
+ public boolean isHighContrastEnabled() {
+ return WindowsNative.isHighContrastEnabled();
+ }
+
+ @Override
+ public boolean isReduceMotionEnabled() {
+ return WindowsNative.isReduceMotionEnabled();
+ }
+
+ @Override
+ public boolean isScreenReaderEnabled() {
+ return WindowsNative.isScreenReaderEnabled();
+ }
private static WindowsImplementation INSTANCE;
// Event type codes; must mirror the CN1EventType enum in cn1_windows.h.
@@ -71,6 +94,7 @@ public class WindowsImplementation extends CodenameOneImplementation {
private static final int EVENT_MOUSE_HWHEEL = 9;
private static final int EVENT_PINCH = 10;
private static final int EVENT_ROTATE = 11;
+ private static final int EVENT_ACCESSIBILITY_ACTION = 12;
// The native gesture events encode their float (incremental scale / radians) as
// an int in 1/10000 units; see CN1_GESTURE_FIXED in cn1_windows.h.
@@ -88,6 +112,10 @@ public class WindowsImplementation extends CodenameOneImplementation {
private L10NManager l10n;
private com.codename1.ui.util.ImageIO imageIO;
private final int[] eventScratch = new int[4];
+ private final Map accessibilityActionTokens = new HashMap();
+ private final Map accessibilityActionTargets =
+ new HashMap();
+ private int nextAccessibilityActionToken = 1;
/**
* Registers the singleton so the Win32 native bootstrap and message loop can
@@ -680,6 +708,12 @@ private void drainInput() {
case EVENT_CLOSE:
Display.getInstance().exitApplication();
break;
+ case EVENT_ACCESSIBILITY_ACTION:
+ AccessibilityActionTarget actionTarget = accessibilityActionTargets.get(Integer.valueOf(key));
+ if (actionTarget != null) {
+ performAccessibilityAction(actionTarget.nodeId, actionTarget.actionId, null);
+ }
+ break;
default:
break;
}
@@ -692,6 +726,97 @@ private void drainInput() {
}
}
+ @Override
+ public void accessibilityTreeChanged(int changeType) {
+ AccessibilityTreeSnapshot tree = getAccessibilityTreeSnapshot();
+ Set liveActionKeys = new HashSet();
+ WindowsNative.accessibilityBegin();
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ int flags = (node.isFocusable() ? 1 : 0) | (node.isFocused() ? 2 : 0)
+ | (Boolean.TRUE.equals(node.getEnabled()) ? 4 : 0)
+ | (Boolean.TRUE.equals(node.getSelected()) ? 8 : 0)
+ | (node.getChecked().ordinal() << 4)
+ | (Boolean.TRUE.equals(node.getExpanded()) ? 64 : 0)
+ | (Boolean.TRUE.equals(node.getInvalid()) ? 128 : 0);
+ com.codename1.ui.geom.Rectangle b = node.getBounds();
+ WindowsNative.accessibilityNode(node.getId(), node.getParentId(), node.getRole().name(), node.getLabel(),
+ accessibilityDescription(node), node.getValue(), b.getX(), b.getY(), b.getWidth(), b.getHeight(), flags);
+ for (AccessibilityAction action : node.getActions()) {
+ if (action.isEnabled()) {
+ String actionKey = accessibilityActionKey(node.getId(), action.getId());
+ liveActionKeys.add(actionKey);
+ WindowsNative.accessibilityAction(node.getId(), action.getId(),
+ accessibilityActionToken(node.getId(), action.getId()),
+ action.getLabel() == null ? action.getId() : action.getLabel());
+ }
+ }
+ }
+ WindowsNative.accessibilityEnd(changeType);
+ Iterator> iterator = accessibilityActionTokens.entrySet().iterator();
+ while (iterator.hasNext()) {
+ Map.Entry entry = iterator.next();
+ if (!liveActionKeys.contains(entry.getKey())) {
+ accessibilityActionTargets.remove(entry.getValue());
+ iterator.remove();
+ }
+ }
+ }
+
+ private int accessibilityActionToken(long nodeId, String actionId) {
+ String key = accessibilityActionKey(nodeId, actionId);
+ Integer existing = accessibilityActionTokens.get(key);
+ if (existing != null) {
+ accessibilityActionTargets.put(existing, new AccessibilityActionTarget(nodeId, actionId));
+ return existing.intValue();
+ }
+ int candidate = nextAccessibilityActionToken;
+ while (accessibilityActionTargets.containsKey(Integer.valueOf(candidate))) {
+ candidate = nextAccessibilityActionToken(candidate);
+ }
+ nextAccessibilityActionToken = nextAccessibilityActionToken(candidate);
+ Integer token = Integer.valueOf(candidate);
+ accessibilityActionTokens.put(key, token);
+ accessibilityActionTargets.put(token, new AccessibilityActionTarget(nodeId, actionId));
+ return candidate;
+ }
+
+ private int nextAccessibilityActionToken(int current) {
+ return current == Integer.MAX_VALUE ? 1 : current + 1;
+ }
+
+ private String accessibilityActionKey(long nodeId, String actionId) {
+ return nodeId + "\n" + actionId;
+ }
+
+ private static final class AccessibilityActionTarget {
+ final long nodeId;
+ final String actionId;
+
+ AccessibilityActionTarget(long nodeId, String actionId) {
+ this.nodeId = nodeId;
+ this.actionId = actionId;
+ }
+ }
+
+ private String accessibilityDescription(AccessibilityNodeSnapshot node) {
+ StringBuilder out = new StringBuilder();
+ if (node.getHint() != null) out.append(node.getHint());
+ if (node.getDescription() != null) {
+ if (out.length() > 0) out.append(". ");
+ out.append(node.getDescription());
+ }
+ if (node.getValidationError() != null) {
+ if (out.length() > 0) out.append(". ");
+ out.append(node.getValidationError());
+ }
+ return out.length() == 0 ? null : out.toString();
+ }
+
+ @Override
+ public boolean isAccessibilityTreeSupported() {
+ return true;
+ }
+
/* --------------------------------------------------- local notifications
* Desktop semantic (mirrors the JavaSE port): while the app runs, a Timer
* fires the notification at its scheduled time and Shell_NotifyIcon shows a
diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java
index c0bc766ff92..24f363c83d3 100644
--- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java
+++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java
@@ -143,12 +143,25 @@ public static native void setTransform(long graphics, float m00, float m10, floa
*/
public static native boolean pollEvent(int[] out);
+ /** Rebuilds the Windows UI Automation virtual fragment tree. */
+ public static native void accessibilityBegin();
+ public static native void accessibilityNode(long id, long parentId, String role, String label,
+ String description, String value, int x, int y, int width, int height, int stateFlags);
+ public static native void accessibilityAction(long nodeId, String actionId, int actionHash, String label);
+ public static native void accessibilityEnd(int changeType);
+
/**
* True when an integrated or external touch digitizer is present, used for
* {@code Display.isTouchScreen()} (queries GetSystemMetrics(SM_DIGITIZER)).
*/
public static native boolean isTouchDevice();
+ public static native boolean isHighContrastEnabled();
+
+ public static native boolean isReduceMotionEnabled();
+
+ public static native boolean isScreenReaderEnabled();
+
/**
* Parks the calling (main) thread to keep the process alive in headless
* screenshot mode, where there is no window message loop. The EDT exits the
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 422bf34b4af..f87d9b1826d 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -5754,6 +5754,90 @@ JAVA_FLOAT com_codename1_impl_ios_IOSNative_getLargerTextScale___R_float(CN1_THR
#endif // !TARGET_OS_WATCH && !TARGET_OS_TV
}
+#if TARGET_OS_WATCH
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isHighContrastEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isDifferentiateWithoutColorEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isReduceMotionEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isReduceTransparencyEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBoldTextEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isInvertColorsEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isGrayscaleEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isOnOffSwitchLabelsEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isScreenReaderEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return 0;
+}
+
+#else
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isHighContrastEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return UIAccessibilityDarkerSystemColorsEnabled() ? 1 : 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isDifferentiateWithoutColorEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ if (@available(iOS 13.0, macCatalyst 13.1, *)) {
+ return UIAccessibilityShouldDifferentiateWithoutColor() ? 1 : 0;
+ }
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isReduceMotionEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return UIAccessibilityIsReduceMotionEnabled() ? 1 : 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isReduceTransparencyEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return UIAccessibilityIsReduceTransparencyEnabled() ? 1 : 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isBoldTextEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return UIAccessibilityIsBoldTextEnabled() ? 1 : 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isInvertColorsEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return UIAccessibilityIsInvertColorsEnabled() ? 1 : 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isGrayscaleEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return UIAccessibilityIsGrayscaleEnabled() ? 1 : 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isOnOffSwitchLabelsEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ if (@available(iOS 13.0, macCatalyst 13.1, *)) {
+ return UIAccessibilityIsOnOffSwitchLabelsEnabled() ? 1 : 0;
+ }
+ return 0;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isScreenReaderEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return UIAccessibilityIsVoiceOverRunning() ? 1 : 0;
+}
+
+#endif // TARGET_OS_WATCH
+
#ifdef INCLUDE_LOCATION_USAGE
CLLocationManager* com_codename1_impl_ios_IOSNative_createCLLocation = nil;
#endif
@@ -13481,6 +13565,217 @@ void com_codename1_impl_ios_IOSNative_announceForAccessibility___java_lang_Strin
#endif // !TARGET_OS_WATCH
}
+#if !TARGET_OS_WATCH
+@interface CN1AccessibilityCustomAction : UIAccessibilityCustomAction
+@property(nonatomic, retain) NSString *cn1ActionId;
+@end
+
+@implementation CN1AccessibilityCustomAction
+@end
+
+@interface CN1AccessibilityElement : UIAccessibilityElement
+@property(nonatomic, assign) long long cn1NodeId;
+@property(nonatomic, retain) NSArray *cn1Actions;
+@end
+
+static NSString *CN1AccessibilityActionId(NSArray *actions, NSString *wanted) {
+ for (NSDictionary *action in actions) {
+ if ([wanted isEqualToString:[action objectForKey:@"id"]]
+ && ![[action objectForKey:@"enabled"] isEqual:@NO]) {
+ return wanted;
+ }
+ }
+ return nil;
+}
+
+static void CN1PerformAccessibilityAction(long long nodeId, NSString *actionId, NSString *argument) {
+ if (actionId == nil) return;
+ JAVA_OBJECT javaAction = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG actionId);
+ JAVA_OBJECT javaArgument = argument == nil ? JAVA_NULL : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG argument);
+ com_codename1_impl_ios_IOSImplementation_performAccessibilityActionFromNative___long_java_lang_String_java_lang_String(
+ CN1_THREAD_GET_STATE_PASS_ARG (JAVA_LONG)nodeId, javaAction, javaArgument);
+}
+
+@implementation CN1AccessibilityElement
+- (BOOL)accessibilityActivate {
+ NSString *action = CN1AccessibilityActionId(self.cn1Actions, @"activate");
+ if (action == nil) action = CN1AccessibilityActionId(self.cn1Actions, @"focus");
+ if (action == nil) return NO;
+ CN1PerformAccessibilityAction(self.cn1NodeId, action, nil);
+ return YES;
+}
+
+- (void)accessibilityIncrement {
+ CN1PerformAccessibilityAction(self.cn1NodeId,
+ CN1AccessibilityActionId(self.cn1Actions, @"increment"), nil);
+}
+
+- (void)accessibilityDecrement {
+ CN1PerformAccessibilityAction(self.cn1NodeId,
+ CN1AccessibilityActionId(self.cn1Actions, @"decrement"), nil);
+}
+
+- (BOOL)accessibilityPerformEscape {
+ NSString *action = CN1AccessibilityActionId(self.cn1Actions, @"dismiss");
+ if (action == nil) return NO;
+ CN1PerformAccessibilityAction(self.cn1NodeId, action, nil);
+ return YES;
+}
+
+- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
+ NSString *action = nil;
+ if (direction == UIAccessibilityScrollDirectionDown || direction == UIAccessibilityScrollDirectionRight
+ || direction == UIAccessibilityScrollDirectionNext) {
+ action = CN1AccessibilityActionId(self.cn1Actions, @"scrollForward");
+ } else {
+ action = CN1AccessibilityActionId(self.cn1Actions, @"scrollBackward");
+ }
+ if (action == nil) return NO;
+ CN1PerformAccessibilityAction(self.cn1NodeId, action, nil);
+ return YES;
+}
+
+- (BOOL)cn1PerformCustomAction:(CN1AccessibilityCustomAction *)action {
+ if (action.cn1ActionId == nil) return NO;
+ CN1PerformAccessibilityAction(self.cn1NodeId, action.cn1ActionId, nil);
+ return YES;
+}
+@end
+
+static NSMutableDictionary *cn1AccessibilityLiveValues;
+
+static id CN1JSONValue(NSDictionary *node, NSString *key) {
+ id value = [node objectForKey:key];
+ return value == [NSNull null] ? nil : value;
+}
+
+static UIAccessibilityTraits CN1AccessibilityTraitsForNode(NSDictionary *node) {
+ NSString *role = CN1JSONValue(node, @"role");
+ UIAccessibilityTraits traits = UIAccessibilityTraitNone;
+ if ([role isEqualToString:@"BUTTON"] || [role isEqualToString:@"TOGGLE_BUTTON"]
+ || [role isEqualToString:@"CHECKBOX"] || [role isEqualToString:@"RADIO_BUTTON"]
+ || [role isEqualToString:@"SWITCH"] || [role isEqualToString:@"TAB"]
+ || [role isEqualToString:@"MENU_ITEM"]) traits |= UIAccessibilityTraitButton;
+ if ([role isEqualToString:@"LINK"]) traits |= UIAccessibilityTraitLink;
+ if ([role isEqualToString:@"IMAGE"]) traits |= UIAccessibilityTraitImage;
+ if ([role isEqualToString:@"STATIC_TEXT"]) traits |= UIAccessibilityTraitStaticText;
+ if ([role isEqualToString:@"SEARCH_FIELD"]) traits |= UIAccessibilityTraitSearchField;
+ if ([role isEqualToString:@"HEADING"] || [CN1JSONValue(node, @"headingLevel") intValue] > 0)
+ traits |= UIAccessibilityTraitHeader;
+ if ([role isEqualToString:@"SLIDER"] || [role isEqualToString:@"SPIN_BUTTON"])
+ traits |= UIAccessibilityTraitAdjustable;
+ if ([CN1JSONValue(node, @"selected") boolValue]) traits |= UIAccessibilityTraitSelected;
+ id enabled = CN1JSONValue(node, @"enabled");
+ if (enabled != nil && ![enabled boolValue]) traits |= UIAccessibilityTraitNotEnabled;
+ if (![[CN1JSONValue(node, @"liveRegion") description] isEqualToString:@"OFF"])
+ traits |= UIAccessibilityTraitUpdatesFrequently;
+ return traits;
+}
+
+static NSString *CN1AccessibilityValueForNode(NSDictionary *node) {
+ NSString *value = CN1JSONValue(node, @"value");
+ NSDictionary *range = CN1JSONValue(node, @"range");
+ if (value == nil && range != nil) {
+ value = CN1JSONValue(range, @"text");
+ if (value == nil) value = [[range objectForKey:@"current"] stringValue];
+ }
+ NSString *checked = CN1JSONValue(node, @"checked");
+ if ([checked isEqualToString:@"CHECKED"]) value = value == nil ? @"Checked" : [value stringByAppendingString:@", Checked"];
+ else if ([checked isEqualToString:@"UNCHECKED"]) value = value == nil ? @"Unchecked" : [value stringByAppendingString:@", Unchecked"];
+ else if ([checked isEqualToString:@"MIXED"]) value = value == nil ? @"Mixed" : [value stringByAppendingString:@", Mixed"];
+ id expanded = CN1JSONValue(node, @"expanded");
+ if (expanded != nil) {
+ NSString *state = [expanded boolValue] ? @"Expanded" : @"Collapsed";
+ value = value == nil ? state : [value stringByAppendingFormat:@", %@", state];
+ }
+ id invalid = CN1JSONValue(node, @"invalid");
+ if (invalid != nil && [invalid boolValue]) value = value == nil ? @"Invalid" : [value stringByAppendingString:@", Invalid"];
+ return value;
+}
+
+void com_codename1_impl_ios_IOSNative_updateAccessibilityTree___java_lang_String_int(
+ CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT json, JAVA_INT changeType) {
+ if (json == JAVA_NULL) return;
+ POOL_BEGIN();
+ NSString *jsonString = toNSString(CN1_THREAD_STATE_PASS_ARG json);
+ NSData *data = [jsonString dataUsingEncoding:NSUTF8StringEncoding];
+ NSDictionary *tree = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
+ NSArray *nodes = [tree objectForKey:@"nodes"];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ UIView *container = (UIView *)[[CodenameOne_GLViewController instance] eaglView];
+ if (container == nil) return;
+ NSMutableArray *elements = [NSMutableArray arrayWithCapacity:[nodes count]];
+ NSMutableDictionary *elementsById = [NSMutableDictionary dictionary];
+ if (cn1AccessibilityLiveValues == nil) cn1AccessibilityLiveValues = [[NSMutableDictionary alloc] init];
+ for (NSDictionary *node in nodes) {
+ CN1AccessibilityElement *element = [[CN1AccessibilityElement alloc] initWithAccessibilityContainer:container];
+ NSNumber *nodeId = [node objectForKey:@"id"];
+ element.cn1NodeId = [nodeId longLongValue];
+ element.cn1Actions = CN1JSONValue(node, @"actions");
+ element.accessibilityLabel = CN1JSONValue(node, @"label");
+ NSString *hint = CN1JSONValue(node, @"hint");
+ NSString *error = CN1JSONValue(node, @"error");
+ element.accessibilityHint = error == nil ? hint : hint == nil ? error : [NSString stringWithFormat:@"%@. %@", hint, error];
+ element.accessibilityValue = CN1AccessibilityValueForNode(node);
+ element.accessibilityIdentifier = CN1JSONValue(node, @"identifier");
+ element.accessibilityTraits = CN1AccessibilityTraitsForNode(node);
+ element.accessibilityViewIsModal = [CN1JSONValue(node, @"modal") boolValue];
+ NSArray *bounds = [node objectForKey:@"bounds"];
+ if ([bounds count] == 4) {
+ CGFloat scale = scaleValue <= 0 ? [UIScreen mainScreen].scale : scaleValue;
+ element.accessibilityFrameInContainerSpace = CGRectMake(
+ [[bounds objectAtIndex:0] doubleValue] / scale,
+ [[bounds objectAtIndex:1] doubleValue] / scale,
+ [[bounds objectAtIndex:2] doubleValue] / scale,
+ [[bounds objectAtIndex:3] doubleValue] / scale);
+ }
+ NSMutableArray *custom = [NSMutableArray array];
+ for (NSDictionary *action in element.cn1Actions) {
+ NSString *actionId = [action objectForKey:@"id"];
+ if ([actionId isEqualToString:@"activate"] || [actionId isEqualToString:@"focus"]
+ || [actionId isEqualToString:@"increment"] || [actionId isEqualToString:@"decrement"]
+ || [actionId isEqualToString:@"dismiss"] || [actionId isEqualToString:@"scrollForward"]
+ || [actionId isEqualToString:@"scrollBackward"] || ![[action objectForKey:@"enabled"] boolValue]) continue;
+ NSString *name = CN1JSONValue(action, @"label");
+ if (name == nil) name = actionId;
+ CN1AccessibilityCustomAction *customAction = [[CN1AccessibilityCustomAction alloc]
+ initWithName:name target:element selector:@selector(cn1PerformCustomAction:)];
+ customAction.cn1ActionId = actionId;
+ [custom addObject:customAction];
+ }
+ element.accessibilityCustomActions = custom;
+ [elements addObject:element];
+ [elementsById setObject:element forKey:nodeId];
+
+ NSString *live = CN1JSONValue(node, @"liveRegion");
+ if (live != nil && ![live isEqualToString:@"OFF"]) {
+ NSString *newValue = [NSString stringWithFormat:@"%@|%@", element.accessibilityLabel ?: @"", element.accessibilityValue ?: @""];
+ NSString *oldValue = [cn1AccessibilityLiveValues objectForKey:nodeId];
+ if (oldValue != nil && ![oldValue isEqualToString:newValue]) {
+ UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification,
+ element.accessibilityLabel ?: element.accessibilityValue);
+ }
+ [cn1AccessibilityLiveValues setObject:newValue forKey:nodeId];
+ }
+ }
+ container.isAccessibilityElement = NO;
+ container.accessibilityElements = elements;
+ if ((changeType & 256) != 0) {
+ UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,
+ [elements count] == 0 ? nil : [elements objectAtIndex:0]);
+ } else {
+ UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil);
+ }
+ });
+ POOL_END();
+}
+#else
+void com_codename1_impl_ios_IOSNative_updateAccessibilityTree___java_lang_String_int(
+ CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT json, JAVA_INT changeType) {
+ // watchOS uses a separate SwiftUI accessibility model.
+}
+#endif
+
// ====================================================================
// Crypto bridge -- implementations of the native methods on IOSNative
// that back com.codename1.security.{Cipher,Signature,SecureRandom,
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index da7451cbee4..00187319f2b 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -1652,6 +1652,51 @@ public boolean isLargerTextEnabled() {
public float getLargerTextScale() {
return nativeInstance.getLargerTextScale();
}
+
+ @Override
+ public boolean isHighContrastEnabled() {
+ return nativeInstance.isHighContrastEnabled();
+ }
+
+ @Override
+ public boolean isDifferentiateWithoutColorEnabled() {
+ return nativeInstance.isDifferentiateWithoutColorEnabled();
+ }
+
+ @Override
+ public boolean isReduceMotionEnabled() {
+ return nativeInstance.isReduceMotionEnabled();
+ }
+
+ @Override
+ public boolean isReduceTransparencyEnabled() {
+ return nativeInstance.isReduceTransparencyEnabled();
+ }
+
+ @Override
+ public boolean isBoldTextEnabled() {
+ return nativeInstance.isBoldTextEnabled();
+ }
+
+ @Override
+ public boolean isInvertColorsEnabled() {
+ return nativeInstance.isInvertColorsEnabled();
+ }
+
+ @Override
+ public boolean isGrayscaleEnabled() {
+ return nativeInstance.isGrayscaleEnabled();
+ }
+
+ @Override
+ public boolean isOnOffSwitchLabelsEnabled() {
+ return nativeInstance.isOnOffSwitchLabelsEnabled();
+ }
+
+ @Override
+ public boolean isScreenReaderEnabled() {
+ return nativeInstance.isScreenReaderEnabled();
+ }
public void flushGraphics() {
@@ -11671,6 +11716,22 @@ public void announceForAccessibility(final Component cmp, final String text) {
IOSNative.announceForAccessibility(text);
}
+ @Override
+ public void accessibilityTreeChanged(int changeType) {
+ IOSNative.updateAccessibilityTree(getAccessibilityTreeSnapshot().toJson(), changeType);
+ }
+
+ @Override
+ public boolean isAccessibilityTreeSupported() {
+ return true;
+ }
+
+ public static void performAccessibilityActionFromNative(long nodeId, String actionId, String argument) {
+ if (instance != null) {
+ instance.performAccessibilityAction(nodeId, actionId, argument);
+ }
+ }
+
// ================================================================
// Crypto bridge -- routes through CN1Crypto.{h,m} in nativeSources/
// (the corresponding native methods live on IOSNative). The defaults
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
index 58a714251cc..ffe5f3b8f14 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
@@ -191,6 +191,15 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre
native boolean isLargerTextEnabled();
native float getLargerTextScale();
+ native boolean isHighContrastEnabled();
+ native boolean isDifferentiateWithoutColorEnabled();
+ native boolean isReduceMotionEnabled();
+ native boolean isReduceTransparencyEnabled();
+ native boolean isBoldTextEnabled();
+ native boolean isInvertColorsEnabled();
+ native boolean isGrayscaleEnabled();
+ native boolean isOnOffSwitchLabelsEnabled();
+ native boolean isScreenReaderEnabled();
// SJH Nov. 17, 2015 : Removing native isMinimized() method because it conflicted with
// tracking on the java side. It caused the app to still be minimized inside start()
@@ -1213,6 +1222,8 @@ native void startTagRead(int requestId, String alertMessage,
public static native void announceForAccessibility(String text);
+ public static native void updateAccessibilityTree(String json, int changeType);
+
// ============================================================
// Crypto bridge -- backed by CN1Crypto.{h,m} in nativeSources/.
//
diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java
new file mode 100644
index 00000000000..6f2f962329e
--- /dev/null
+++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java
@@ -0,0 +1,236 @@
+/*
+ * 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.codenameone.developerguide.snippets;
+
+import com.codename1.ui.Button;
+import com.codename1.ui.AccessibilityColorVisionDeficiency;
+import com.codename1.ui.CN;
+import com.codename1.ui.Component;
+import com.codename1.ui.Form;
+import com.codename1.ui.Label;
+import com.codename1.ui.accessibility.AccessibilityAction;
+import com.codename1.ui.accessibility.AccessibilityAssertions;
+import com.codename1.ui.accessibility.AccessibilityCheckedState;
+import com.codename1.ui.accessibility.AccessibilityChildProvider;
+import com.codename1.ui.accessibility.AccessibilityCollectionInfo;
+import com.codename1.ui.accessibility.AccessibilityCollectionItemInfo;
+import com.codename1.ui.accessibility.AccessibilityGrouping;
+import com.codename1.ui.accessibility.AccessibilityInspector;
+import com.codename1.ui.accessibility.AccessibilityLiveRegion;
+import com.codename1.ui.accessibility.AccessibilityNode;
+import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
+import com.codename1.ui.accessibility.AccessibilityRange;
+import com.codename1.ui.accessibility.AccessibilityRole;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
+import com.codename1.ui.geom.Rectangle;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Compiled source snippets for the accessibility semantics guide chapter. */
+public class AccessibilitySemanticsSnippets {
+
+ public void configureButton() {
+ // tag::accessibility-semantics-button[]
+ Button save = new Button("Save");
+ save.getSemantics()
+ .setHint("Saves the edited profile")
+ .setIdentifier("profile-save");
+ // end::accessibility-semantics-button[]
+ }
+
+ public void configureRoles(Component wifiSwitch, Label title) {
+ // tag::accessibility-semantics-roles[]
+ wifiSwitch.getSemantics()
+ .setRole(AccessibilityRole.SWITCH)
+ .setLabel("Wi-Fi")
+ .setChecked(AccessibilityCheckedState.CHECKED)
+ .setEnabled(Boolean.TRUE)
+ .setHint("Double tap to turn Wi-Fi off");
+
+ title.getSemantics()
+ .setHeadingLevel(2)
+ .setIdentifier("network-heading");
+ // end::accessibility-semantics-roles[]
+ }
+
+ public void configureRange(Component rating) {
+ // tag::accessibility-semantics-range[]
+ rating.getSemantics()
+ .setRole(AccessibilityRole.SLIDER)
+ .setLabel("Rating")
+ .setRange(new AccessibilityRange(0, 5, 3, 1, "3 out of 5"));
+ // end::accessibility-semantics-range[]
+ }
+
+ public void configureAction(Component card) {
+ // tag::accessibility-semantics-action[]
+ card.getSemantics().addAction(new AccessibilityAction(
+ "archive",
+ "Archive message",
+ new AccessibilityAction.Handler() {
+ public boolean perform(Component component, Object argument) {
+ archiveMessage();
+ return true;
+ }
+ }
+ ));
+ // end::accessibility-semantics-action[]
+ }
+
+ public void configureGrouping(Component summaryCard) {
+ // tag::accessibility-semantics-grouping[]
+ summaryCard.getSemantics()
+ .setRole(AccessibilityRole.GENERIC)
+ .setGrouping(AccessibilityGrouping.MERGE_DESCENDANTS);
+ // end::accessibility-semantics-grouping[]
+ }
+
+ public void configureVirtualChildren(Component chart, final List points) {
+ // tag::accessibility-semantics-virtual[]
+ chart.getSemantics().setChildProvider(new AccessibilityChildProvider() {
+ public List getAccessibilityChildren(Component owner) {
+ List result = new ArrayList();
+ for (ChartPoint point : points) {
+ AccessibilityNode node = new AccessibilityNode("point-" + point.getId());
+ node.setRole(AccessibilityRole.IMAGE)
+ .setLabel(point.getLabel())
+ .setValue(point.getFormattedValue())
+ .setBounds(point.getBounds());
+ result.add(node);
+ }
+ return result;
+ }
+ });
+ // end::accessibility-semantics-virtual[]
+ }
+
+ public void configureTraversal(Component cancel, Component continueButton, Component help) {
+ // tag::accessibility-semantics-traversal[]
+ cancel.getSemantics().setSortKey(1);
+ continueButton.getSemantics().setSortKey(2);
+ help.getSemantics().setTraversalAfter(continueButton);
+ // end::accessibility-semantics-traversal[]
+ }
+
+ public void configureLiveRegion(Label status) {
+ // tag::accessibility-semantics-live[]
+ status.getSemantics()
+ .setRole(AccessibilityRole.ALERT)
+ .setLiveRegion(AccessibilityLiveRegion.POLITE);
+ status.setText("Upload complete");
+ // end::accessibility-semantics-live[]
+ }
+
+ public void configureCollection(Component grid, Component cell) {
+ // tag::accessibility-semantics-collection[]
+ grid.getSemantics().setCollectionInfo(
+ new AccessibilityCollectionInfo(20, 3, false,
+ AccessibilityCollectionInfo.SELECTION_MULTIPLE));
+
+ cell.getSemantics().setCollectionItemInfo(
+ new AccessibilityCollectionItemInfo(
+ 4, 1, 2, 1, 5, 20, 1, false));
+ // end::accessibility-semantics-collection[]
+ }
+
+ public String inspect(Form myForm, int screenX, int screenY) {
+ // tag::accessibility-semantics-inspection[]
+ AccessibilityTreeSnapshot tree =
+ AccessibilityInspector.snapshot(myForm);
+
+ AccessibilityAssertions.assertNoErrors(tree);
+ AccessibilityAssertions.assertNoUnlabeledInteractiveNodes(tree);
+
+ String diagnosticJson = tree.toJson();
+ AccessibilityNodeSnapshot hit = tree.getNodeAt(screenX, screenY);
+ // end::accessibility-semantics-inspection[]
+ return diagnosticJson + (hit == null ? "" : hit.getLabel());
+ }
+
+ public void adaptToAccessibilityPreferences(Component animatedChart, Label connectionStatus) {
+ // tag::accessibility-preferences[]
+ if (CN.isHighContrastEnabled()) {
+ animatedChart.setUIID("HighContrastChart");
+ }
+
+ // Important state always uses text or an icon as well as color. The
+ // preference is an extra signal, not permission to use color alone.
+ if (CN.isDifferentiateWithoutColorEnabled()
+ || CN.getColorVisionDeficiency() != AccessibilityColorVisionDeficiency.NONE) {
+ connectionStatus.setText("Disconnected: action required");
+ }
+
+ if (CN.isReduceMotionEnabled()) {
+ animatedChart.putClientProperty("animate", Boolean.FALSE);
+ }
+ if (CN.isReduceTransparencyEnabled()) {
+ animatedChart.setUIID("OpaqueChart");
+ }
+ // end::accessibility-preferences[]
+ }
+
+ public void repairCustomControl(Component iconOnlyButton) {
+ // tag::accessibility-repair[]
+ iconOnlyButton.getSemantics()
+ .setRole(AccessibilityRole.BUTTON)
+ .setLabel("Delete message")
+ .setHint("Removes this message from the conversation")
+ .setIdentifier("message-delete")
+ .addAction(new AccessibilityAction(
+ AccessibilityAction.ACTIVATE,
+ null,
+ new AccessibilityAction.Handler() {
+ public boolean perform(Component component, Object argument) {
+ deleteMessage();
+ return true;
+ }
+ }));
+ // end::accessibility-repair[]
+ }
+
+ public void assertAccessibleForm(Form form) {
+ // tag::accessibility-regression-assertions[]
+ AccessibilityTreeSnapshot tree = AccessibilityInspector.snapshot(form);
+ AccessibilityAssertions.assertNoErrors(tree);
+ AccessibilityAssertions.assertNoUnlabeledInteractiveNodes(tree);
+
+ AccessibilityNodeSnapshot save = tree.getNodeByIdentifier("profile-save");
+ assert save.getRole() == AccessibilityRole.BUTTON;
+ assert save.getAction(AccessibilityAction.ACTIVATE) != null;
+ // end::accessibility-regression-assertions[]
+ }
+
+ private void archiveMessage() {
+ }
+
+ private void deleteMessage() {
+ }
+
+ /** Minimal model contract used by the virtual-descendant example. */
+ public interface ChartPoint {
+ String getId();
+ String getLabel();
+ String getFormattedValue();
+ Rectangle getBounds();
+ }
+}
diff --git a/docs/developer-guide/Accessibility-Semantics.asciidoc b/docs/developer-guide/Accessibility-Semantics.asciidoc
new file mode 100644
index 00000000000..ec8612fe157
--- /dev/null
+++ b/docs/developer-guide/Accessibility-Semantics.asciidoc
@@ -0,0 +1,232 @@
+= Accessibility Semantics
+
+Codename One components are lightweight: they're painted by Codename One instead of being represented by a native widget on every platform. The accessibility semantics API builds an explicit, immutable virtual tree from those components and presents it to VoiceOver, TalkBack, Windows UI Automation, AT-SPI, Java Access Bridge, and web accessibility APIs.
+
+The semantic tree is separate from the visual tree. This is important for renderer-backed controls, merged cards, decorative content, custom controls, and applications that need a reading order different from paint order.
+
+== Built-in semantics
+
+Standard components work without extra configuration. `Button`, `CheckBox`, `RadioButton`, `Slider`, text fields, lists, tables, tabs, labels, dialogs, and containers infer appropriate roles, values, states, collection metadata, and standard actions. Renderer-backed `List` rows are exposed as stable virtual children even though no `Component` exists for each painted row.
+
+`Component.setAccessibilityText()` remains supported. It's a compatibility alias for the semantic label. New code should use `getSemantics()` when it needs more than a label.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-button,indent=0]
+
+Changes to text, selection, focus, enabled state, bounds, scrolling, ranges, tabs, and the current `Form` automatically invalidate the native semantic tree. Custom components should call `accessibilityChanged()` after a semantic value changes outside a standard setter.
+
+== Roles, names, values, and state
+
+`AccessibilityRole` contains portable control roles including buttons, toggle buttons, switches, checkboxes, radio buttons, headings, links, images, text and search fields, sliders, progress indicators, lists, grids, rows, cells, headers, tabs, dialogs, alerts, menus, toolbars, combo boxes, trees, and separators. A port maps an unsupported role to the closest native role without discarding its label or state.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-roles,indent=0]
+
+Tri-state controls use `UNCHECKED`, `CHECKED`, or `MIXED`. Nullable Boolean properties distinguish an explicit state from an unspecified state. Available properties include selected, expanded, enabled, invalid, busy, read-only, required, multiline, obscured, pressed, and current. `setValidationError()` supplies both the error message and invalid state. `setPaneTitle()` describes a newly displayed pane or screen, and `setModal(true)` identifies a modal scope.
+
+Use `setRoleDescription()` only when the platform role isn't sufficiently specific. Localize labels, hints, descriptions, errors, pane titles, role descriptions, and custom action labels.
+
+== Ranges and editable values
+
+`AccessibilityRange` describes minimum, maximum, current value, increment, and an optional spoken value. The spoken value is useful for ranges such as ratings and durations where a raw number is ambiguous.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-range,indent=0]
+
+Editable controls can provide `SET_VALUE` or `SET_TEXT` actions. Sliders and other adjustable controls should expose `INCREMENT` and `DECREMENT`. Standard components infer these actions.
+
+== Custom accessibility actions
+
+An `AccessibilityAction` has a stable ID, a localized label, an enabled state, and a handler. Handlers always execute on the Codename One EDT, including when the action originates on a native accessibility thread.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-action,indent=0]
+
+Use the standard IDs in `AccessibilityAction` for activation, long press, increment, decrement, set value, set text, selection, character or word cursor movement, focus, show-on-screen, dismiss, expand, collapse, scrolling, and clipboard operations. A custom ID must be stable and its label must not be null.
+
+== Grouping and virtual descendants
+
+`AccessibilityGrouping` controls how a component participates in the semantic tree:
+
+* `AUTO` uses normal component inference.
+* `LEAF` exposes the component but none of its descendants.
+* `GROUP` exposes the component as a semantic container and retains its descendants.
+* `MERGE_DESCENDANTS` combines descendant labels, descriptions, values, state, and actions into one node.
+* `EXCLUDE` removes the component but promotes its descendants to the semantic parent.
+* `EXCLUDE_SUBTREE` removes the component and all descendants.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-grouping,indent=0]
+
+For content that's painted without child components, attach an `AccessibilityChildProvider`, or add standalone `AccessibilityNode` children. Every virtual node must use a stable `virtualKey`. Stable keys preserve native focus while rows are updated or recycled.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-virtual,indent=0]
+
+Virtual bounds are relative to the owning component. Component nodes use their clipped absolute bounds.
+
+== Traversal order
+
+Visual order is the default reading order. `setSortKey()` assigns an ordered semantic key among siblings. `setTraversalBefore()` and `setTraversalAfter()` express a direct relationship when a numeric order would be fragile.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-traversal,indent=0]
+
+Keep traversal relationships within the same semantic parent. The tree builder resolves these constraints deterministically and preserves stable node IDs.
+
+== Live regions and screen changes
+
+Set `AccessibilityLiveRegion.POLITE` for non-urgent updates and `ASSERTIVE` for errors or urgent status. `OFF` is the default. Live changes produce the corresponding platform notification or ARIA live update.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-live,indent=0]
+
+Form changes and nodes with a pane title produce screen or pane transition notifications. `Display.announceForAccessibility()` remains available for an exceptional announcement that isn't represented by persistent UI. Prefer a live region for state that's visible on screen.
+
+== User accessibility preferences
+
+Semantics describe what a control means. Accessibility preferences describe how the user wants the interface presented. Read these preferences through `Display` or the matching static shortcuts in `CN`:
+
+[cols="2,3"]
+|===
+|API |Meaning
+
+|`isHighContrastEnabled()`
+|The user requests stronger foreground and background contrast.
+
+|`isDifferentiateWithoutColorEnabled()`
+|The user requests labels, shapes, or patterns in addition to color.
+
+|`getColorVisionDeficiency()`
+|Returns `NONE`, `PROTANOPIA`, `DEUTERANOPIA`, `TRITANOPIA`, `MONOCHROMACY`, or `UNKNOWN`.
+
+|`isReduceMotionEnabled()`
+|Nonessential motion should be removed or replaced with an instant or cross-fade change.
+
+|`isReduceTransparencyEnabled()`
+|Blur and translucent surfaces should use an opaque alternative.
+
+|`isBoldTextEnabled()`
+|The system requests a heavier text weight.
+
+|`isInvertColorsEnabled()` and `isGrayscaleEnabled()`
+|The operating system is transforming the displayed colors.
+
+|`isOnOffSwitchLabelsEnabled()`
+|Switches should include a visible on/off distinction.
+
+|`isScreenReaderEnabled()`
+|A screen reader or touch-exploration service is active.
+|===
+
+An unavailable preference returns `false`; an unavailable color-vision mode returns `UNKNOWN`. Don't hide accessibility support behind `isScreenReaderEnabled()`. A screen reader can start after the application launches, and other assistive technologies consume the same semantic tree.
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-preferences,indent=0]
+----
+
+IMPORTANT: Never use `getColorVisionDeficiency() == NONE` as permission to convey information only through color. The setting can be unavailable, a person might not enable a system filter, and contrast can change with lighting and display hardware. Design the default interface with redundant cues, then use preferences for further adaptation.
+
+Preference detection uses each platform's public signals. iOS and Mac Catalyst expose contrast, differentiate-without-color, motion, transparency, bold text, inversion, grayscale, switch labels, and VoiceOver; Apple doesn't expose the selected color-filter type, so the deficiency value is `UNKNOWN`. Android reads high-text contrast, display inversion and color-correction mode, animation scale, font-weight adjustment, and touch exploration. Windows reads High Contrast, client-area animation, and screen-reader settings. Linux detects the GTK HighContrast theme, disabled GTK animation, and ATK bridge activation. JavaScript uses `forced-colors`, `prefers-contrast`, `prefers-reduced-motion`, and `prefers-reduced-transparency`. The Java SE simulator provides deterministic controls for every portable signal.
+
+== Collections
+
+`AccessibilityCollectionInfo` describes row and column counts, hierarchy, and selection mode. `AccessibilityCollectionItemInfo` describes row and column index, spans, position and size in a set, nesting level, and header status.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-collection,indent=0]
+
+Row and column indexes are zero-based. Position and set size are one-based; use `-1` when unknown. Lists, tables, tabs, and their items infer this metadata where possible.
+
+Renderer-backed lists keep the full model size in their collection metadata but materialize only the visible rows, a small navigation buffer, and the selected row as virtual children. Forward and backward semantic scroll actions move the window. This keeps accessibility-tree work proportional to the viewport instead of invoking a cell renderer for every row whenever a large list scrolls.
+
+== Inspecting and testing semantics
+
+`AccessibilityInspector` returns an immutable snapshot. A snapshot is safe to inspect without racing native accessibility callbacks and includes stable IDs, hierarchy, bounds, resolved state, actions, range and collection data. `toJson()` is suitable for diagnostic reports and tooling.
+
+Semantic setters always mark the cached tree dirty. Pull-based native ports rebuild it on the Codename One EDT only while assistive technology is active or when a tool explicitly requests a snapshot; the browser keeps its off-screen ARIA projection synchronized continuously. Applications should still publish complete semantics unconditionally—this runtime optimization is deliberately transparent to application code.
+
+[source,java]
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-semantics-inspection,indent=0]
+
+`AccessibilityAssertions.audit()` reports machine-readable `AccessibilityIssue` values for unlabeled controls, invalid ranges, duplicate identifiers or actions, invalid collection positions, missing parents, cycles, unnamed dialogs, and unlabeled custom actions. Warnings also identify suspicious role/state combinations and empty bounds.
+
+The Java SE Component Inspector provides *Copy Accessibility Tree JSON* and *Audit Accessibility Tree* actions. Tests should assert resolved semantic properties instead of platform-specific spoken sentences. The `scripts/hellocodenameone` suite contains the cross-port conformance fixture used by native CI.
+
+== Diagnosing and correcting an inaccessible screen
+
+Accessibility review works best as a repeatable loop instead of a final checklist:
+
+. Use the keyboard to reach every operation. Verify visible focus, logical order, escape from modal UI, and operation without pointer gestures.
+. Open *Tools → Component Inspector*. Select the form or a suspicious component, then choose *Audit Accessibility Tree*. Start with errors, such as an unlabeled interactive node, invalid range, or unnamed dialog.
+. Choose *Copy Accessibility Tree JSON* and inspect the resolved role, label, value, state, actions, bounds, and child order. This catches cases where the visual component hierarchy doesn't match the virtual accessibility hierarchy.
+. Use *Simulate → Accessibility Preferences* to test high contrast, reduced motion, reduced transparency, inversion, grayscale, and each color-vision filter. Also test *Simulate → Larger Text* at Accessibility 5. Look for clipped text, color-only state, invisible focus, unreadable overlays, and motion that remains essential to understanding the result.
+. Correct the component API or theme. Run the audit again, add a focused assertion, and verify the native screen reader before considering the issue closed.
+
+The simulator menu changes the values returned by `Display` and `CN`. Color-vision, inversion, and grayscale choices also filter the simulator canvas, so screenshots show approximately what remains distinguishable. These filters are diagnostic approximations; they don't replace testing with platform display filters or users.
+
+.Accessibility preferences in the Java SE simulator
+image::img/accessibility-simulator-preferences.png[Java SE simulator with the Accessibility Preferences menu open,scaledwidth=80%]
+
+The following icon-only custom control is painted and responds to a pointer, but initially has no usable name, role, or keyboard-equivalent action. The audit reports it as an unlabeled interactive node. Repair the semantic contract at the custom-control boundary:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-repair,indent=0]
+----
+
+Don't fix the warning by adding a label alone if the role, current state, range, or available actions are still wrong. For example, a custom switch needs `SWITCH`, checked state, and activation; a chart needs virtual descendants for its meaningful data points; a renderer-backed grid needs collection and item metadata.
+
+.Auditing the semantic tree in the Component Inspector
+image::img/accessibility-component-inspector-audit.png[Component Inspector showing an accessibility audit,scaledwidth=80%]
+
+=== Assertions that prevent regressions
+
+Snapshot the finished form and assert behavior that matters to a user. Prefer stable identifiers and resolved semantics over implementation class names or platform-specific speech:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/AccessibilitySemanticsSnippets.java[tag=accessibility-regression-assertions,indent=0]
+----
+
+Keep targeted assertions for traversal order, validation errors, live-region behavior, ranges, and stable virtual keys. The `scripts/hellocodenameone` accessibility fixture invokes every portable preference API and exercises the resolved semantic tree on each native CI platform.
+
+=== Native verification
+
+The simulator finds portable problems, but the final pass must use VoiceOver, TalkBack, Narrator, Orca, or the browser accessibility tree. Navigate in both directions, activate every action, change adjustable values, enter and leave collections, trigger errors, and confirm that focus moves sensibly after a dialog, navigation, deletion, or live update. Record the platform, assistive-technology version, input method, and failed semantic node in the bug report; attach the inspector JSON when the native result differs from the portable snapshot.
+
+== Platform mappings
+
+[cols="1,2,2"]
+|===
+|Target |Native representation |Notable behavior
+
+|iOS, iPadOS, and Mac Catalyst
+|`UIAccessibilityElement` instances owned by the rendering view
+|Traits, values, frames, custom actions, adjustable actions, live announcements, and screen/layout transitions
+
+|Android
+|`AccessibilityNodeProvider` virtual descendants
+|Class/role mapping, checked and mixed state, headings, pane titles, errors, ranges, collections, focus, hit testing, and custom actions
+
+|Windows native
+|UI Automation fragment-root provider
+|Virtual fragments, control types, names, values, state, bounds, navigation, focus lookup, hit testing, and action dispatch
+
+|Linux native
+|Transparent GTK semantic widgets exposed through ATK/AT-SPI
+|Roles, names, descriptions, state, hierarchy, bounds, and action dispatch
+
+|Java SE simulator and desktop
+|Swing `AccessibleContext` virtual children
+|Roles, states, values, geometry, accessible actions, and Component Inspector integration
+
+|JavaScript
+|A synchronized off-screen DOM tree
+|ARIA roles, names, state, ranges, live regions, collection metadata, traversal order, and action routing; the painted canvas is hidden from the accessibility tree
+|===
+
+The API intentionally goes beyond a label-only model: it includes stable virtual children, explicit traversal constraints, collection spans and set metadata, pane transitions, machine-readable inspection, and built-in semantic audits. Platform accessibility tools remain the final verification step because screen readers can apply platform-specific presentation rules.
+
+For underlying platform guidance, see the Apple https://developer.apple.com/documentation/uikit/uiaccessibility-protocol[UIAccessibility protocol], Android https://developer.android.com/guide/topics/ui/accessibility/principles[accessibility principles], Microsoft https://learn.microsoft.com/windows/win32/winauto/uiauto-providersoverview[UI Automation provider overview], and W3C https://www.w3.org/TR/wai-aria-1.2/[WAI-ARIA].
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index b1c598cf5c2..8f9ab5b8474 100644
--- a/docs/developer-guide/developer-guide.asciidoc
+++ b/docs/developer-guide/developer-guide.asciidoc
@@ -57,6 +57,8 @@ include::css.asciidoc[]
include::The-Components-Of-Codename-One.asciidoc[]
+include::Accessibility-Semantics.asciidoc[]
+
include::Media-And-Audio.asciidoc[]
include::Animations.asciidoc[]
diff --git a/docs/developer-guide/img/accessibility-component-inspector-audit.png b/docs/developer-guide/img/accessibility-component-inspector-audit.png
new file mode 100644
index 00000000000..923240ed932
Binary files /dev/null and b/docs/developer-guide/img/accessibility-component-inspector-audit.png differ
diff --git a/docs/developer-guide/img/accessibility-simulator-preferences.png b/docs/developer-guide/img/accessibility-simulator-preferences.png
new file mode 100644
index 00000000000..01d578b5285
Binary files /dev/null and b/docs/developer-guide/img/accessibility-simulator-preferences.png differ
diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt
index d9445e2bef1..10c8fbc45c9 100644
--- a/docs/developer-guide/languagetool-accept.txt
+++ b/docs/developer-guide/languagetool-accept.txt
@@ -604,6 +604,9 @@ bitmask
DeX
Siri
devirtualization
+# Coordinate variable names used in accessibility hit-testing examples.
+screenX
+screenY
# AR / VR terms (Augmented-Reality.asciidoc, Virtual-Reality.asciidoc)
raycasts?
equirectangular
diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/accessibility/AccessibilitySemanticsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/accessibility/AccessibilitySemanticsTest.java
new file mode 100644
index 00000000000..53f1cd2522b
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/ui/accessibility/AccessibilitySemanticsTest.java
@@ -0,0 +1,272 @@
+/*
+ * 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.accessibility;
+
+import com.codename1.junit.UITestBase;
+import com.codename1.junit.FormTest;
+import com.codename1.ui.Button;
+import com.codename1.ui.AccessibilityColorVisionDeficiency;
+import com.codename1.ui.CheckBox;
+import com.codename1.ui.Component;
+import com.codename1.ui.Container;
+import com.codename1.ui.Form;
+import com.codename1.ui.Display;
+import com.codename1.ui.Label;
+import com.codename1.ui.Slider;
+import com.codename1.ui.TextField;
+import com.codename1.ui.list.DefaultListModel;
+import com.codename1.ui.list.ListCellRenderer;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class AccessibilitySemanticsTest extends UITestBase {
+
+ @FormTest
+ void exposesPortableAccessibilityPreferencesWithSafeDefaults() {
+ Display display = Display.getInstance();
+ assertEquals(AccessibilityColorVisionDeficiency.UNKNOWN, display.getColorVisionDeficiency());
+ assertFalse(display.isHighContrastEnabled());
+ assertFalse(display.isDifferentiateWithoutColorEnabled());
+ assertFalse(display.isReduceMotionEnabled());
+ assertFalse(display.isReduceTransparencyEnabled());
+ assertFalse(display.isBoldTextEnabled());
+ assertFalse(display.isInvertColorsEnabled());
+ assertFalse(display.isGrayscaleEnabled());
+ assertFalse(display.isOnOffSwitchLabelsEnabled());
+ assertFalse(display.isScreenReaderEnabled());
+ }
+
+ @FormTest
+ void infersRolesStatesValuesAndActionsForBuiltInControls() {
+ Form form = form();
+ Button button = place(form, new Button("Save"), 10, 10, 100, 30);
+ CheckBox checkbox = place(form, new CheckBox("Remember me"), 10, 50, 140, 30);
+ checkbox.setSelected(true);
+ Slider slider = place(form, new Slider(), 10, 90, 200, 30);
+ slider.setEditable(true);
+ slider.setMinValue(10);
+ slider.setMaxValue(50);
+ slider.setProgress(30);
+ TextField field = place(form, new TextField("hello"), 10, 130, 200, 35);
+
+ AccessibilityTreeSnapshot tree = AccessibilityInspector.snapshot(form);
+ AccessibilityNodeSnapshot buttonNode = find(tree, button);
+ AccessibilityNodeSnapshot checkNode = find(tree, checkbox);
+ AccessibilityNodeSnapshot sliderNode = find(tree, slider);
+ AccessibilityNodeSnapshot fieldNode = find(tree, field);
+
+ assertEquals(AccessibilityRole.BUTTON, buttonNode.getRole());
+ assertEquals("Save", buttonNode.getLabel());
+ button.getSemantics().setIdentifier("save-button");
+ tree = AccessibilityInspector.snapshot(form);
+ assertEquals(button, tree.getNodeByIdentifier("save-button").getComponent());
+ assertNotNull(buttonNode.getAction(AccessibilityAction.ACTIVATE));
+ assertEquals(AccessibilityRole.CHECKBOX, checkNode.getRole());
+ assertEquals(AccessibilityCheckedState.CHECKED, checkNode.getChecked());
+ assertEquals(30d, sliderNode.getRange().getCurrent());
+ assertNotNull(sliderNode.getAction(AccessibilityAction.INCREMENT));
+ assertEquals(AccessibilityRole.TEXT_FIELD, fieldNode.getRole());
+ assertEquals("hello", fieldNode.getLabel());
+ assertNotNull(fieldNode.getAction(AccessibilityAction.SET_TEXT));
+ }
+
+ @FormTest
+ void explicitSemanticsOverrideInferredValuesAndLegacyTextMapsToLabel() {
+ Form form = form();
+ Button button = place(form, new Button("Visual"), 10, 10, 100, 30);
+ button.setAccessibilityText("Legacy");
+ assertEquals("Legacy", button.getSemantics().getLabel());
+ button.getSemantics().setLabel("Semantic").setRole(AccessibilityRole.SWITCH)
+ .setChecked(AccessibilityCheckedState.MIXED).setHint("Changes mode");
+
+ AccessibilityNodeSnapshot node = find(AccessibilityInspector.snapshot(form), button);
+ assertEquals("Semantic", node.getLabel());
+ assertEquals("Semantic", button.getAccessibilityText());
+ assertEquals(AccessibilityRole.SWITCH, node.getRole());
+ assertEquals(AccessibilityCheckedState.MIXED, node.getChecked());
+ assertEquals("Changes mode", node.getHint());
+ }
+
+ @FormTest
+ void groupingCanMergeOrPromoteDescendants() {
+ Form form = form();
+ Container merged = place(form, new Container(), 0, 0, 200, 100);
+ Label first = place(merged, new Label("First"), 0, 0, 100, 30);
+ place(merged, new Label("Second"), 0, 35, 100, 30);
+ merged.getSemantics().setRole(AccessibilityRole.GENERIC)
+ .setGrouping(AccessibilityGrouping.MERGE_DESCENDANTS);
+
+ AccessibilityTreeSnapshot tree = AccessibilityInspector.snapshot(form);
+ AccessibilityNodeSnapshot mergedNode = find(tree, merged);
+ assertTrue(mergedNode.getLabel().contains("First"));
+ assertTrue(mergedNode.getLabel().contains("Second"));
+ assertTrue(mergedNode.getChildIds().isEmpty());
+ assertNull(findOrNull(tree, first));
+
+ merged.getSemantics().setGrouping(AccessibilityGrouping.EXCLUDE);
+ tree = AccessibilityInspector.snapshot(form);
+ assertNull(findOrNull(tree, merged));
+ assertNotNull(findOrNull(tree, first));
+ }
+
+ @FormTest
+ void sortKeysProduceExplicitTraversalOrder() {
+ Form form = form();
+ Button second = place(form, new Button("Second"), 0, 0, 100, 30);
+ Button first = place(form, new Button("First"), 0, 40, 100, 30);
+ second.getSemantics().setSortKey(2);
+ first.getSemantics().setSortKey(1);
+
+ AccessibilityTreeSnapshot tree = AccessibilityInspector.snapshot(form);
+ AccessibilityNodeSnapshot formNode = find(tree, form);
+ int firstIndex = formNode.getChildIds().indexOf(Long.valueOf(find(tree, first).getId()));
+ int secondIndex = formNode.getChildIds().indexOf(Long.valueOf(find(tree, second).getId()));
+ assertTrue(firstIndex >= 0 && secondIndex >= 0 && firstIndex < secondIndex);
+ }
+
+ @FormTest
+ void rendererBackedListsExposeStableVirtualCollectionItems() {
+ Form form = form();
+ com.codename1.ui.List list = new com.codename1.ui.List(
+ new DefaultListModel("Alpha", "Beta", "Gamma"));
+ place(form, list, 0, 0, 200, 160);
+ list.setSelectedIndex(1);
+
+ AccessibilityTreeSnapshot first = AccessibilityInspector.snapshot(form);
+ AccessibilityNodeSnapshot listNode = find(first, list);
+ assertEquals(3, listNode.getChildIds().size());
+ AccessibilityNodeSnapshot beta = first.getNode(listNode.getChildIds().get(1).longValue());
+ assertTrue(beta.getLabel().contains("Beta"));
+ assertEquals(Boolean.TRUE, beta.getSelected());
+ assertEquals(2, beta.getCollectionItemInfo().getPositionInSet());
+ long stableId = beta.getId();
+
+ list.setSelectedIndex(2);
+ AccessibilityTreeSnapshot second = AccessibilityInspector.snapshot(form);
+ AccessibilityNodeSnapshot secondList = find(second, list);
+ assertEquals(stableId, secondList.getChildIds().get(1).longValue());
+ assertEquals(Boolean.FALSE, second.getNode(stableId).getSelected());
+ }
+
+ @FormTest
+ void largeRendererBackedListsOnlyMaterializeTheViewport() {
+ Form form = form();
+ String[] values = new String[1000];
+ for (int i = 0; i < values.length; i++) {
+ values[i] = "Row " + i;
+ }
+ final int[] rendererCalls = new int[1];
+ final Label rendererLabel = new Label();
+ com.codename1.ui.List list = new com.codename1.ui.List(
+ new DefaultListModel(values));
+ list.setRenderer(new ListCellRenderer() {
+ public Component getListCellRendererComponent(com.codename1.ui.List list,
+ String value, int index, boolean selected) {
+ rendererCalls[0]++;
+ rendererLabel.setText(value);
+ return rendererLabel;
+ }
+
+ public Component getListFocusComponent(com.codename1.ui.List list) {
+ return null;
+ }
+ });
+ place(form, list, 0, 0, 200, 120);
+ rendererCalls[0] = 0;
+
+ AccessibilityTreeSnapshot tree = AccessibilityInspector.snapshot(form);
+ AccessibilityNodeSnapshot listNode = find(tree, list);
+ assertTrue(listNode.getChildIds().size() < 50,
+ "A 1000-row list should expose only its viewport-sized virtual window");
+ assertTrue(rendererCalls[0] < 100,
+ "Building list semantics must not invoke the renderer for every model row");
+ assertEquals(1000, listNode.getCollectionInfo().getRowCount());
+ assertNotNull(listNode.getAction(AccessibilityAction.SCROLL_FORWARD));
+ assertNotNull(listNode.getAction(AccessibilityAction.SCROLL_BACKWARD));
+ }
+
+ @FormTest
+ void clearingAValidationErrorClearsTheImpliedInvalidState() {
+ AccessibilityNode node = new AccessibilityNode("validation-test");
+ node.setValidationError("Required");
+ assertEquals(Boolean.TRUE, node.getInvalid());
+ node.setValidationError(null);
+ assertNull(node.getValidationError());
+ assertNull(node.getInvalid());
+ }
+
+ @FormTest
+ void auditsInvalidRangesAndUnlabeledCustomActions() {
+ Form form = form();
+ Component custom = place(form, new Label(), 0, 0, 100, 30);
+ custom.getSemantics().setRole(AccessibilityRole.SLIDER)
+ .setRange(new AccessibilityRange(10, 5, 20, -1))
+ .addAction(new AccessibilityAction("custom", null,
+ new AccessibilityAction.Handler() {
+ public boolean perform(Component component, Object argument) { return true; }
+ }));
+
+ List issues = AccessibilityAssertions.audit(AccessibilityInspector.snapshot(form));
+ assertTrue(hasIssue(issues, "invalid-range"));
+ assertTrue(hasIssue(issues, "unlabeled-custom-action"));
+ assertThrows(AssertionError.class,
+ () -> AccessibilityAssertions.assertNoErrors(AccessibilityInspector.snapshot(form)));
+ }
+
+ private Form form() {
+ Form form = new Form("Accessible form");
+ form.setWidth(400);
+ form.setHeight(600);
+ form.getContentPane().setWidth(400);
+ form.getContentPane().setHeight(600);
+ return form;
+ }
+
+ private T place(Container parent, T component, int x, int y, int w, int h) {
+ parent.add(component);
+ component.setX(x);
+ component.setY(y);
+ component.setWidth(w);
+ component.setHeight(h);
+ return component;
+ }
+
+ private AccessibilityNodeSnapshot find(AccessibilityTreeSnapshot tree, Component component) {
+ AccessibilityNodeSnapshot node = findOrNull(tree, component);
+ assertNotNull(node, "No semantic node for " + component);
+ return node;
+ }
+
+ private AccessibilityNodeSnapshot findOrNull(AccessibilityTreeSnapshot tree, Component component) {
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ if (node.getComponent() == component && node.getVirtualKey() == null) return node;
+ }
+ return null;
+ }
+
+ private boolean hasIssue(List issues, String code) {
+ for (AccessibilityIssue issue : issues) if (code.equals(issue.getCode())) return true;
+ return false;
+ }
+}
diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/accessibility/AccessibilityTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/accessibility/AccessibilityTest.java
index 3494bf9f11e..9a9b9890a7e 100644
--- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/accessibility/AccessibilityTest.java
+++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/accessibility/AccessibilityTest.java
@@ -1,20 +1,313 @@
package com.codenameone.examples.hellocodenameone.tests.accessibility;
+import com.codename1.ui.Button;
+import com.codename1.ui.AccessibilityColorVisionDeficiency;
+import com.codename1.ui.CN;
+import com.codename1.ui.CheckBox;
+import com.codename1.ui.Component;
+import com.codename1.ui.Container;
import com.codename1.ui.Display;
+import com.codename1.ui.Form;
+import com.codename1.ui.Label;
+import com.codename1.ui.Slider;
+import com.codename1.ui.accessibility.AccessibilityAction;
+import com.codename1.ui.accessibility.AccessibilityAssertions;
+import com.codename1.ui.accessibility.AccessibilityCheckedState;
+import com.codename1.ui.accessibility.AccessibilityCollectionInfo;
+import com.codename1.ui.accessibility.AccessibilityCollectionItemInfo;
+import com.codename1.ui.accessibility.AccessibilityGrouping;
+import com.codename1.ui.accessibility.AccessibilityInspector;
+import com.codename1.ui.accessibility.AccessibilityLiveRegion;
+import com.codename1.ui.accessibility.AccessibilityManager;
+import com.codename1.ui.accessibility.AccessibilityNode;
+import com.codename1.ui.accessibility.AccessibilityNodeSnapshot;
+import com.codename1.ui.accessibility.AccessibilityRange;
+import com.codename1.ui.accessibility.AccessibilityRole;
+import com.codename1.ui.accessibility.AccessibilityTreeSnapshot;
+import com.codename1.ui.geom.Rectangle;
+import com.codename1.ui.list.DefaultListModel;
+import com.codename1.ui.list.ListCellRenderer;
import com.codenameone.examples.hellocodenameone.tests.BaseTest;
+/** Cross-port semantics conformance test. This runs in every native cn1ss job. */
public class AccessibilityTest extends BaseTest {
+ private volatile boolean customActionPerformed;
+
@Override
public boolean runTest() throws Exception {
- com.codename1.ui.CN.callSerially(() -> {
- String expected = "Testing accessibility announcement";
- // Just verify that invoking this doesn't crash the app
- Display.getInstance().announceForAccessibility(expected);
- done();
+ CN.callSerially(new Runnable() {
+ public void run() {
+ try {
+ runSemanticAssertions();
+ } catch (Throwable t) {
+ fail("Accessibility semantic assertions failed: " + t);
+ }
+ }
});
return true;
}
+ private void runSemanticAssertions() {
+ assertAccessibilityPreferencesAvailable();
+ AccessibilityNode validation = new AccessibilityNode("validation-state");
+ validation.setValidationError("Required");
+ assertEqual(Boolean.TRUE, validation.getInvalid(), "validation error implies invalid state");
+ validation.setValidationError(null);
+ assertEqual(null, validation.getInvalid(), "clearing validation error clears invalid state");
+
+ final Form form = new Form("Accessibility conformance");
+ size(form, 0, 0, 600, 1400);
+ size(form.getContentPane(), 0, 0, 600, 1400);
+ form.getSemantics().setPaneTitle("Settings").setRole(AccessibilityRole.GENERIC);
+
+ Label heading = add(form, new Label("Preferences"), 10, 10, 300, 40);
+ heading.getSemantics().setHeadingLevel(2).setIdentifier("preferences-heading");
+
+ Button save = add(form, new Button("Save"), 10, 60, 120, 40);
+ save.getSemantics().setHint("Saves all changes").setSortKey(2)
+ .addAction(new AccessibilityAction("share", "Share settings",
+ new AccessibilityAction.Handler() {
+ public boolean perform(Component component, Object argument) {
+ customActionPerformed = true;
+ return true;
+ }
+ }));
+
+ CheckBox notifications = add(form, new CheckBox("Notifications"), 10, 110, 220, 40);
+ notifications.setSelected(true);
+ notifications.getSemantics().setSortKey(1).setRequired(Boolean.TRUE);
+
+ Slider volume = add(form, new Slider(), 10, 160, 260, 40);
+ volume.setEditable(true);
+ volume.getSemantics().setLabel("Volume")
+ .setRange(new AccessibilityRange(0, 100, 35, 5, "35 percent"));
+
+ Label error = add(form, new Label("Password is too short"), 10, 210, 300, 40);
+ error.getSemantics().setRole(AccessibilityRole.ALERT).setLiveRegion(AccessibilityLiveRegion.ASSERTIVE)
+ .setValidationError("Password is too short").setInvalid(Boolean.TRUE);
+
+ Container collection = add(form, new Container(), 10, 260, 300, 150);
+ collection.getSemantics().setRole(AccessibilityRole.GRID)
+ .setLabel("Accounts")
+ .setCollectionInfo(new AccessibilityCollectionInfo(2, 1, false,
+ AccessibilityCollectionInfo.SELECTION_SINGLE));
+ Label firstRow = add(collection, new Label("Personal"), 0, 0, 250, 50);
+ firstRow.getSemantics().setRole(AccessibilityRole.CELL).setSelected(Boolean.TRUE)
+ .setCollectionItemInfo(new AccessibilityCollectionItemInfo(0, 1, 0, 1, 1, 2, 1, false));
+ Label secondRow = add(collection, new Label("Business"), 0, 55, 250, 50);
+ secondRow.getSemantics().setRole(AccessibilityRole.CELL).setSelected(Boolean.FALSE)
+ .setCollectionItemInfo(new AccessibilityCollectionItemInfo(1, 1, 0, 1, 2, 2, 1, false));
+
+ Container merged = add(form, new Container(), 10, 420, 300, 80);
+ merged.getSemantics().setRole(AccessibilityRole.GENERIC).setGrouping(AccessibilityGrouping.MERGE_DESCENDANTS);
+ add(merged, new Label("Billing"), 0, 0, 100, 30);
+ add(merged, new Label("monthly"), 110, 0, 100, 30);
+
+ Label semanticSwitch = add(form, new Label("Automatic updates"), 10, 510, 260, 40);
+ semanticSwitch.getSemantics().setRole(AccessibilityRole.SWITCH)
+ .setChecked(AccessibilityCheckedState.MIXED).setExpanded(Boolean.TRUE);
+
+ Container semanticList = add(form, new Container(), 10, 560, 300, 90);
+ semanticList.getSemantics().setRole(AccessibilityRole.LIST).setLabel("Recent projects")
+ .setCollectionInfo(new AccessibilityCollectionInfo(2, 1, false,
+ AccessibilityCollectionInfo.SELECTION_NONE));
+ Label semanticListItem = add(semanticList, new Label("Project Aurora"), 0, 0, 250, 35);
+ semanticListItem.getSemantics().setRole(AccessibilityRole.LIST_ITEM)
+ .setCollectionItemInfo(new AccessibilityCollectionItemInfo(1, 2));
+
+ Label semanticTab = add(form, new Label("Overview"), 10, 660, 160, 40);
+ semanticTab.getSemantics().setRole(AccessibilityRole.TAB).setSelected(Boolean.TRUE);
+
+ Container semanticDialog = add(form, new Container(), 10, 710, 300, 90);
+ semanticDialog.getSemantics().setRole(AccessibilityRole.DIALOG).setLabel("Confirm deletion")
+ .setPaneTitle("Confirmation").setModal(true);
+
+ Container virtualHost = add(form, new Container(), 10, 810, 300, 100);
+ virtualHost.getSemantics().setRole(AccessibilityRole.LIST).setLabel("Virtual results")
+ .addChild(new AccessibilityNode("result-42").setRole(AccessibilityRole.LIST_ITEM)
+ .setLabel("Virtual result").setBounds(new Rectangle(0, 0, 280, 45))
+ .setCollectionItemInfo(new AccessibilityCollectionItemInfo(1, 1)));
+
+ Button disabled = add(form, new Button("Unavailable action"), 10, 920, 220, 40);
+ disabled.setEnabled(false);
+
+ String[] rows = new String[1000];
+ for (int i = 0; i < rows.length; i++) {
+ rows[i] = "Row " + i;
+ }
+ final int[] rendererCalls = new int[1];
+ final Label rendererLabel = new Label();
+ com.codename1.ui.List largeList = new com.codename1.ui.List(
+ new DefaultListModel(rows));
+ largeList.setRenderer(new ListCellRenderer() {
+ public Component getListCellRendererComponent(com.codename1.ui.List list,
+ String value, int index, boolean selected) {
+ rendererCalls[0]++;
+ rendererLabel.setText(value);
+ return rendererLabel;
+ }
+
+ public Component getListFocusComponent(com.codename1.ui.List list) {
+ return null;
+ }
+ });
+ add(form, largeList, 10, 980, 300, 160);
+ rendererCalls[0] = 0;
+
+ AccessibilityTreeSnapshot first = AccessibilityInspector.snapshot(form);
+ AccessibilityNodeSnapshot headingNode = find(first, heading);
+ AccessibilityNodeSnapshot saveNode = find(first, save);
+ AccessibilityNodeSnapshot checkboxNode = find(first, notifications);
+ AccessibilityNodeSnapshot volumeNode = find(first, volume);
+ AccessibilityNodeSnapshot errorNode = find(first, error);
+ AccessibilityNodeSnapshot collectionNode = find(first, collection);
+ AccessibilityNodeSnapshot mergedNode = find(first, merged);
+ AccessibilityNodeSnapshot switchNode = find(first, semanticSwitch);
+ AccessibilityNodeSnapshot listNode = find(first, semanticList);
+ AccessibilityNodeSnapshot tabNode = find(first, semanticTab);
+ AccessibilityNodeSnapshot dialogNode = find(first, semanticDialog);
+ AccessibilityNodeSnapshot virtualHostNode = find(first, virtualHost);
+ AccessibilityNodeSnapshot largeListNode = find(first, largeList);
+
+ assertEqual(AccessibilityRole.HEADING, headingNode.getRole(), "heading role");
+ assertEqual(2, headingNode.getHeadingLevel(), "heading level");
+ assertEqual("preferences-heading", headingNode.getIdentifier(), "automation identifier");
+ assertEqual(AccessibilityRole.BUTTON, saveNode.getRole(), "inferred button role");
+ assertTrue(saveNode.getAction(AccessibilityAction.ACTIVATE) != null, "standard activation action");
+ assertTrue(saveNode.getAction("share") != null, "custom action");
+ assertEqual(AccessibilityCheckedState.CHECKED, checkboxNode.getChecked(), "checked state");
+ assertEqual(Boolean.TRUE, checkboxNode.getRequired(), "required state");
+ assertEqual(35.0, volumeNode.getRange().getCurrent(), "range value");
+ assertEqual(5.0, volumeNode.getRange().getStep(), "range step");
+ assertEqual(AccessibilityLiveRegion.ASSERTIVE, errorNode.getLiveRegion(), "live region");
+ assertEqual(Boolean.TRUE, errorNode.getInvalid(), "validation state");
+ assertEqual(2, collectionNode.getCollectionInfo().getRowCount(), "collection rows");
+ assertEqual(2, collectionNode.getChildIds().size(), "collection children");
+ assertEqual(2, find(first, secondRow).getCollectionItemInfo().getPositionInSet(), "set position");
+ assertTrue(mergedNode.getLabel().indexOf("Billing") >= 0, "merged first label");
+ assertTrue(mergedNode.getLabel().indexOf("monthly") >= 0, "merged second label");
+ assertEqual(0, mergedNode.getChildIds().size(), "merged descendants hidden");
+ assertTrue(findOrNull(first, firstRow) != null, "collection descendant retained");
+ assertTrue(findOrNull(first, merged.getComponentAt(0)) == null, "merged descendant omitted");
+ assertEqual(AccessibilityRole.SWITCH, switchNode.getRole(), "switch role");
+ assertEqual(AccessibilityCheckedState.MIXED, switchNode.getChecked(), "mixed switch state");
+ assertEqual(Boolean.TRUE, switchNode.getExpanded(), "expanded state");
+ assertEqual(AccessibilityRole.LIST, listNode.getRole(), "list role");
+ assertEqual(AccessibilityRole.LIST_ITEM, find(first, semanticListItem).getRole(), "list item role");
+ assertEqual(AccessibilityRole.TAB, tabNode.getRole(), "tab role");
+ assertEqual(Boolean.TRUE, tabNode.getSelected(), "selected tab state");
+ assertEqual(AccessibilityRole.DIALOG, dialogNode.getRole(), "dialog role");
+ assertTrue(dialogNode.isModal(), "modal dialog state");
+ assertEqual("Confirmation", dialogNode.getPaneTitle(), "pane transition title");
+ assertEqual(Boolean.FALSE, find(first, disabled).getEnabled(), "disabled state");
+ assertEqual(1, virtualHostNode.getChildIds().size(), "virtual accessibility descendant");
+ AccessibilityNodeSnapshot virtualNode = first.getNode(virtualHostNode.getChildIds().get(0).longValue());
+ assertEqual("result-42", virtualNode.getVirtualKey(), "stable virtual key");
+ assertEqual(AccessibilityRole.LIST_ITEM, virtualNode.getRole(), "virtual child role");
+ assertEqual("Virtual result", first.getNodeAt(20, 830).getLabel(), "virtual child hit testing");
+ assertEqual(1000, largeListNode.getCollectionInfo().getRowCount(), "large list collection size");
+ assertTrue(largeListNode.getChildIds().size() < 50,
+ "large list semantics are bounded to the viewport");
+ assertTrue(rendererCalls[0] < 100,
+ "large list semantics do not render every model row");
+ assertTrue(largeListNode.getAction(AccessibilityAction.SCROLL_FORWARD) != null,
+ "large list exposes forward semantic scrolling");
+ assertTrue(largeListNode.getAction(AccessibilityAction.SCROLL_BACKWARD) != null,
+ "large list exposes backward semantic scrolling");
+
+ AccessibilityNodeSnapshot formNode = find(first, form);
+ int checkboxPosition = formNode.getChildIds().indexOf(Long.valueOf(checkboxNode.getId()));
+ int savePosition = formNode.getChildIds().indexOf(Long.valueOf(saveNode.getId()));
+ assertTrue(checkboxPosition >= 0 && checkboxPosition < savePosition, "explicit traversal order");
+ assertTrue(first.getNodeAt(20, 70) != null, "semantic hit testing");
+ AccessibilityAssertions.assertNoErrors(first);
+ AccessibilityAssertions.assertNoUnlabeledInteractiveNodes(first);
+
+ String json = first.toJson();
+ assertTrue(json.indexOf("\"role\":\"HEADING\"") >= 0, "serialized role");
+ assertTrue(json.indexOf("\"liveRegion\":\"ASSERTIVE\"") >= 0, "serialized live region");
+ assertTrue(json.indexOf("\"collection\"") >= 0, "serialized collection metadata");
+
+ long stableSaveId = saveNode.getId();
+ notifications.setSelected(false);
+ AccessibilityTreeSnapshot second = AccessibilityInspector.snapshot(form);
+ assertEqual(stableSaveId, find(second, save).getId(), "stable semantic id");
+ assertEqual(AccessibilityCheckedState.UNCHECKED, find(second, notifications).getChecked(), "updated state");
+ assertTrue(second.getGeneration() > first.getGeneration(), "tree generation changes");
+
+ assertTrue(Display.getInstance().isAccessibilityTreeSupported(),
+ "native port exposes the virtual accessibility tree");
+ form.show();
+ assertTrue(AccessibilityManager.getInstance().performAction(stableSaveId, "share", null),
+ "custom action accepted");
+ CN.callSerially(new Runnable() {
+ public void run() {
+ try {
+ assertTrue(customActionPerformed, "custom action dispatched on EDT");
+ AccessibilityTreeSnapshot nativeSnapshot = AccessibilityInspector.currentSnapshot();
+ assertEqual(stableSaveId, find(nativeSnapshot, save).getId(),
+ "current native semantic snapshot");
+ AccessibilityAssertions.assertNoErrors(nativeSnapshot);
+ Display.getInstance().announceForAccessibility("Testing accessibility announcement");
+ done();
+ } catch (Throwable t) {
+ fail("Accessibility action assertions failed: " + t);
+ }
+ }
+ });
+ }
+
+ private void assertAccessibilityPreferencesAvailable() {
+ AccessibilityColorVisionDeficiency colorVision = Display.getInstance().getColorVisionDeficiency();
+ assertTrue(colorVision != null, "color-vision preference has a portable value");
+ assertTrue(Display.getInstance().getLargerTextScale() > 0,
+ "larger-text scale is positive");
+
+ // Invoke every portable preference on every native CI target. The values
+ // legitimately depend on the host, but none of these calls may throw or
+ // require an initialized semantic tree.
+ Display.getInstance().isHighContrastEnabled();
+ Display.getInstance().isDifferentiateWithoutColorEnabled();
+ Display.getInstance().isReduceMotionEnabled();
+ Display.getInstance().isReduceTransparencyEnabled();
+ Display.getInstance().isBoldTextEnabled();
+ Display.getInstance().isInvertColorsEnabled();
+ Display.getInstance().isGrayscaleEnabled();
+ Display.getInstance().isOnOffSwitchLabelsEnabled();
+ Display.getInstance().isScreenReaderEnabled();
+
+ if (colorVision == AccessibilityColorVisionDeficiency.MONOCHROMACY) {
+ assertTrue(Display.getInstance().isGrayscaleEnabled(),
+ "monochromacy implies grayscale presentation");
+ }
+ }
+
+ private T add(Container parent, T component, int x, int y, int width, int height) {
+ parent.add(component);
+ size(component, x, y, width, height);
+ return component;
+ }
+
+ private void size(Component component, int x, int y, int width, int height) {
+ component.setX(x);
+ component.setY(y);
+ component.setWidth(width);
+ component.setHeight(height);
+ }
+
+ private AccessibilityNodeSnapshot find(AccessibilityTreeSnapshot tree, Component component) {
+ AccessibilityNodeSnapshot result = findOrNull(tree, component);
+ assertTrue(result != null, "semantic node exists for " + component.getClass().getName());
+ return result;
+ }
+
+ private AccessibilityNodeSnapshot findOrNull(AccessibilityTreeSnapshot tree, Component component) {
+ for (AccessibilityNodeSnapshot node : tree.getNodes().values()) {
+ if (node.getComponent() == component && node.getVirtualKey() == null) return node;
+ }
+ return null;
+ }
+
@Override
public boolean shouldTakeScreenshot() {
return false;