From 14b3145f7fda84672fbca73c95d33bfcc6ee410a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:44:24 +0300 Subject: [PATCH 1/8] Route Mac editor navigation through sequenced input commands --- .../com/codename1/ui/editor/EditorView.java | 26 ++++++++-- .../nativeSources/CN1AppKitTextInput.m | 18 +++++-- Ports/MacPort/nativeSources/CN1MacTextInput.h | 2 + Ports/MacPort/nativeSources/METALView.m | 49 +++++++++++++++++++ .../Rich-Text-And-Code-Editing.asciidoc | 11 +++++ .../ui/editor/EditorViewInputTest.java | 34 +++++++++++++ 6 files changed, 130 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 0671e450fcc..d2f6062d6c8 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -1987,10 +1987,18 @@ public void onKeyCommand(int command, int modifiers) { moveCaret(lineJump ? doc.length() : caretVertical(1), extend); break; case KEY_HOME: - lineHome(extend); + if (lineJump) { + moveCaret(0, extend); + } else { + lineHome(extend); + } break; case KEY_END: - lineEndKey(extend); + if (lineJump) { + moveCaret(doc.length(), extend); + } else { + lineEndKey(extend); + } break; case KEY_PAGE_UP: moveCaret(caretVertical(-Math.max(1, contentHeight() / getLineHeight())), extend); @@ -1999,10 +2007,18 @@ public void onKeyCommand(int command, int modifiers) { moveCaret(caretVertical(Math.max(1, contentHeight() / getLineHeight())), extend); break; case KEY_BACKSPACE: - deleteBackward(); + if (word && !hasSelection() && editable) { + replaceRange(wordLeft(), caret, "", true); + } else { + deleteBackward(); + } break; case KEY_DELETE: - deleteForward(); + if (word && !hasSelection() && editable) { + replaceRange(caret, wordRight(), "", true); + } else { + deleteForward(); + } break; case KEY_COPY: copySelection(); @@ -2067,7 +2083,7 @@ public void onEditorAction(int action) { @Override public TextInputState getEditingState() { - return new TextInputState(doc.getText(), getSelectionStart(), getSelectionEnd(), composingStart, composingEnd); + return new TextInputState(doc.getText(), anchor < 0 ? caret : anchor, caret, composingStart, composingEnd); } /// Measures all insertion positions with one bulk request per directional run. Subclasses diff --git a/Ports/MacPort/nativeSources/CN1AppKitTextInput.m b/Ports/MacPort/nativeSources/CN1AppKitTextInput.m index f5a07d36a79..8f90594da40 100644 --- a/Ports/MacPort/nativeSources/CN1AppKitTextInput.m +++ b/Ports/MacPort/nativeSources/CN1AppKitTextInput.m @@ -69,8 +69,14 @@ void CN1MacTextInputNotifySelection(NSRange sel) { if (!session.pureEditor) { return; } + NSUInteger anchor = session.selectionAnchor; + if (anchor != sel.location && anchor != NSMaxRange(sel)) { + anchor = sel.location; + } + NSUInteger focus = anchor == sel.location ? NSMaxRange(sel) : sel.location; + session.selectionAnchor = anchor; com_codename1_impl_ios_IOSImplementation_tiSetSelection___int_int_int( - CN1_THREAD_GET_STATE_PASS_ARG (JAVA_INT)sel.location, (JAVA_INT)NSMaxRange(sel), + CN1_THREAD_GET_STATE_PASS_ARG (JAVA_INT)anchor, (JAVA_INT)focus, (JAVA_INT)[session nextEditSeq]); } @@ -216,8 +222,9 @@ - (void)startWithText:(NSString *)initialText // changed, so clamp rather than trust it -- an out of range NSRange is a // crash, not a wrong caret. selStart = MIN(MAX(selStart, 0), len); - selEnd = MIN(MAX(selEnd, selStart), len); - self.selectedRange = NSMakeRange(selStart, selEnd - selStart); + selEnd = MIN(MAX(selEnd, 0), len); + self.selectionAnchor = selStart; + self.selectedRange = NSMakeRange(MIN(selStart, selEnd), MAX(selStart, selEnd) - MIN(selStart, selEnd)); self.markedRange = NSMakeRange(NSNotFound, 0); self.multiline = multiline; _active = YES; @@ -411,8 +418,9 @@ JAVA_VOID com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_Stri } NSInteger len = (NSInteger)session.text.length; NSInteger start = MIN(MAX((NSInteger)selStart, 0), len); - NSInteger end = MIN(MAX((NSInteger)selEnd, start), len); - session.selectedRange = NSMakeRange(start, end - start); + NSInteger end = MIN(MAX((NSInteger)selEnd, 0), len); + session.selectionAnchor = start; + session.selectedRange = NSMakeRange(MIN(start, end), MAX(start, end) - MIN(start, end)); com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_TRUE); [session completeEditorCommand:(int)seq]; }); diff --git a/Ports/MacPort/nativeSources/CN1MacTextInput.h b/Ports/MacPort/nativeSources/CN1MacTextInput.h index 246455b5e4a..b0575cbeb85 100644 --- a/Ports/MacPort/nativeSources/CN1MacTextInput.h +++ b/Ports/MacPort/nativeSources/CN1MacTextInput.h @@ -54,6 +54,8 @@ void CN1MacTextInputNotifyEditorAction(void); @property (nonatomic, copy) NSString *text; @property (nonatomic) NSRange selectedRange; +/// Direction is separate from AppKit's ordered NSRange; Java uses anchor/focus offsets. +@property (nonatomic) NSUInteger selectionAnchor; @property (nonatomic) NSRange markedRange; /// Whether the session accepts a newline as text rather than as "done". diff --git a/Ports/MacPort/nativeSources/METALView.m b/Ports/MacPort/nativeSources/METALView.m index 89bdd51cf8c..d30fa87abce 100644 --- a/Ports/MacPort/nativeSources/METALView.m +++ b/Ports/MacPort/nativeSources/METALView.m @@ -2116,6 +2116,11 @@ - (NSUInteger)cn1VerticalTarget:(NSRange)sel up:(BOOL)up inText:(NSString *)text /// in each case. Forgotten whenever the selection stops matching what was /// recorded, which is how an ordinary click or an insertion starts a fresh one. - (NSUInteger)cn1SelectionAnchor:(NSRange)sel { + CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; + if (session.pureEditor && (session.selectionAnchor == sel.location + || session.selectionAnchor == NSMaxRange(sel))) { + return session.selectionAnchor; + } if (selectionAnchorValid && (selectionAnchor == sel.location || selectionAnchor == NSMaxRange(sel))) { return selectionAnchor; @@ -2126,6 +2131,7 @@ - (NSUInteger)cn1SelectionAnchor:(NSRange)sel { } - (void)cn1RememberAnchor:(NSUInteger)anchor { + [CN1MacTextInputSession sharedSession].selectionAnchor = anchor; selectionAnchor = anchor; selectionAnchorValid = YES; } @@ -2186,6 +2192,49 @@ - (void)doCommandBySelector:(SEL)selector { NSRange sel = session.selectedRange; NSUInteger len = session.text.length; + if (session.pureEditor) { + // TextInputClient owns visual lines and the direction of its selection. + // AppKit still interprets the event first, so input methods get first refusal. + int command = 0; + int modifiers = 0; + NSString *name = NSStringFromSelector(selector); + NSString *suffix = @"AndModifySelection:"; + if ([name hasSuffix:suffix]) { + modifiers |= 1; // TextInputClient.MOD_SHIFT + name = [[name substringToIndex:name.length - suffix.length] stringByAppendingString:@":"]; + } + if ([name isEqualToString:@"moveWordLeft:"] || [name isEqualToString:@"moveWordBackward:"]) { + command = 1; modifiers |= 4; // KEY_LEFT, MOD_ALT + } else if ([name isEqualToString:@"moveWordRight:"] || [name isEqualToString:@"moveWordForward:"]) { + command = 2; modifiers |= 4; // KEY_RIGHT, MOD_ALT + } else if ([name isEqualToString:@"moveUp:"]) { + command = 3; + } else if ([name isEqualToString:@"moveDown:"]) { + command = 4; + } else if ([name isEqualToString:@"moveToBeginningOfLine:"] || [name isEqualToString:@"moveToLeftEndOfLine:"]) { + command = 5; + } else if ([name isEqualToString:@"moveToEndOfLine:"] || [name isEqualToString:@"moveToRightEndOfLine:"]) { + command = 6; + } else if ([name isEqualToString:@"moveToBeginningOfDocument:"]) { + command = 5; modifiers |= 2; // KEY_HOME, MOD_CTRL + } else if ([name isEqualToString:@"moveToEndOfDocument:"]) { + command = 6; modifiers |= 2; // KEY_END, MOD_CTRL + } else if ([name isEqualToString:@"pageUp:"] || [name isEqualToString:@"scrollPageUp:"]) { + command = 7; + } else if ([name isEqualToString:@"pageDown:"] || [name isEqualToString:@"scrollPageDown:"]) { + command = 8; + } else if ([name isEqualToString:@"deleteWordBackward:"]) { + command = 9; modifiers |= 4; + } else if ([name isEqualToString:@"deleteWordForward:"]) { + command = 10; modifiers |= 4; + } + if (command != 0) { + [self cn1SendEditorKeyCommand:command modifiers:modifiers]; + [self cn1ForgetAnchor]; + return; + } + } + if (selector == @selector(insertNewline:)) { if (session.multiline) { [self insertText:@"\n" replacementRange:NSMakeRange(NSNotFound, 0)]; diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index 9e3ff072f93..b404c3c67d7 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -89,6 +89,17 @@ autocorrection and automatic capitalization. The immutable https://www.codenameone.com/javadoc/com/codename1/ui/TextInputState.html[`TextInputState`] returns the authoritative text, selection, and composing range to the port after each edit. +Selection offsets in `TextInputState` are the anchor and focus, in that order. They aren't sorted: +a backward selection can have a larger start offset than end offset. The macOS bridge keeps that +direction alongside the ordered range required by AppKit, so extending a selection and typing +into it preserve the same range in Java and native code. + +On macOS, pure editors receive word movement and deletion, visual line boundaries, document boundaries, +and page movement through `onKeyCommand(...)`. AppKit interprets the key event first. The editor owns +its visual line layout; the port waits for its updated state before replaying later native edits. +Word commands carry `MOD_ALT`, document Home/End commands carry `MOD_CTRL`, and selection commands +also carry `MOD_SHIFT`. These callbacks don't change the legacy `TextArea` editing path. + All offsets in this contract are UTF-16 indices, matching Java `String`, Android `Editable`, and Apple string indices. The document canonicalizes CRLF and bare CR line endings to LF before it updates the caret, undo history, formatting runs, or platform state. Port authors must deliver composition updates diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java index 3f76bc4bd06..d0396955b8c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java @@ -147,4 +147,38 @@ void aSecondCommitFollowsTheFirstRatherThanReplacingIt() { f.commitText("cd"); assertEquals("abcd", f.getText()); } + @FormTest + void semanticDocumentCommandsPreserveSelectionDirection() { + EditorView v = show("one\ntwo\nthree"); + v.onKeyCommand(TextInputClient.KEY_HOME, TextInputClient.MOD_CTRL | TextInputClient.MOD_SHIFT); + TextInputState state = v.getEditingState(); + assertEquals(13, state.getSelectionStart()); + assertEquals(0, state.getSelectionEnd()); + v.commitText("replacement"); + assertEquals("replacement", v.getText()); + v.onKeyCommand(TextInputClient.KEY_HOME, TextInputClient.MOD_CTRL); + assertEquals(0, v.getEditingState().getSelectionEnd()); + v.onKeyCommand(TextInputClient.KEY_END, TextInputClient.MOD_CTRL); + assertEquals(11, v.getEditingState().getSelectionEnd()); + } + + @FormTest + void wordDeletionRespectsSelectionUndoAndReadOnly() { + EditorView v = show("one two three"); + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + assertEquals("one two ", v.getText()); + v.onKeyCommand(TextInputClient.KEY_UNDO, 0); + assertEquals("one two three", v.getText()); + v.setSelectionRange(0, 0); + v.onKeyCommand(TextInputClient.KEY_DELETE, TextInputClient.MOD_ALT); + assertEquals(" two three", v.getText()); + v.setSelectionRange(4, 1); + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + assertEquals(" three", v.getText()); + v.setEditableState(false); + v.onKeyCommand(TextInputClient.KEY_DELETE, TextInputClient.MOD_ALT); + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + assertEquals(" three", v.getText()); + } + } From 73d5950071f7bd0984ae02c126efd28c1579cd9e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:06:52 +0300 Subject: [PATCH 2/8] Preserve AppKit paragraph navigation in pure editors --- .../com/codename1/ui/editor/EditorView.java | 22 +++++++++++++++++-- Ports/MacPort/nativeSources/METALView.m | 14 +++++++++++- .../Rich-Text-And-Code-Editing.asciidoc | 5 +++-- .../ui/editor/EditorViewInputTest.java | 19 ++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index d2f6062d6c8..589aba01a9a 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -1953,6 +1953,20 @@ public void deleteSurroundingText(int before, int after) { replaceRange(s, e, "", true); } + private int paragraphBoundary(boolean forward, boolean advance) { + int line = doc.lineOfOffset(caret); + if (forward) { + if (advance && caret == doc.getLineEnd(line) && line + 1 < doc.getLineCount()) { + line++; + } + return doc.getLineEnd(line); + } + if (advance && caret == doc.getLineStart(line) && line > 0) { + line--; + } + return doc.getLineStart(line); + } + @Override public void onKeyCommand(int command, int modifiers) { boolean extend = (modifiers & MOD_SHIFT) != 0; @@ -1981,14 +1995,16 @@ public void onKeyCommand(int command, int modifiers) { } break; case KEY_UP: - moveCaret(lineJump ? 0 : caretVertical(-1), extend); + moveCaret(lineJump ? 0 : word ? paragraphBoundary(false, true) : caretVertical(-1), extend); break; case KEY_DOWN: - moveCaret(lineJump ? doc.length() : caretVertical(1), extend); + moveCaret(lineJump ? doc.length() : word ? paragraphBoundary(true, true) : caretVertical(1), extend); break; case KEY_HOME: if (lineJump) { moveCaret(0, extend); + } else if (word) { + moveCaret(paragraphBoundary(false, false), extend); } else { lineHome(extend); } @@ -1996,6 +2012,8 @@ public void onKeyCommand(int command, int modifiers) { case KEY_END: if (lineJump) { moveCaret(doc.length(), extend); + } else if (word) { + moveCaret(paragraphBoundary(true, false), extend); } else { lineEndKey(extend); } diff --git a/Ports/MacPort/nativeSources/METALView.m b/Ports/MacPort/nativeSources/METALView.m index d30fa87abce..7f1dc8b510b 100644 --- a/Ports/MacPort/nativeSources/METALView.m +++ b/Ports/MacPort/nativeSources/METALView.m @@ -2203,7 +2203,19 @@ - (void)doCommandBySelector:(SEL)selector { modifiers |= 1; // TextInputClient.MOD_SHIFT name = [[name substringToIndex:name.length - suffix.length] stringByAppendingString:@":"]; } - if ([name isEqualToString:@"moveWordLeft:"] || [name isEqualToString:@"moveWordBackward:"]) { + if ([name isEqualToString:@"moveBackward:"]) { + command = 1; + } else if ([name isEqualToString:@"moveForward:"]) { + command = 2; + } else if ([name isEqualToString:@"moveParagraphBackward:"]) { + command = 3; modifiers |= 4; + } else if ([name isEqualToString:@"moveParagraphForward:"]) { + command = 4; modifiers |= 4; + } else if ([name isEqualToString:@"moveToBeginningOfParagraph:"]) { + command = 5; modifiers |= 4; + } else if ([name isEqualToString:@"moveToEndOfParagraph:"]) { + command = 6; modifiers |= 4; + } else if ([name isEqualToString:@"moveWordLeft:"] || [name isEqualToString:@"moveWordBackward:"]) { command = 1; modifiers |= 4; // KEY_LEFT, MOD_ALT } else if ([name isEqualToString:@"moveWordRight:"] || [name isEqualToString:@"moveWordForward:"]) { command = 2; modifiers |= 4; // KEY_RIGHT, MOD_ALT diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index b404c3c67d7..e4a05bd04d0 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -94,8 +94,9 @@ a backward selection can have a larger start offset than end offset. The macOS b direction alongside the ordered range required by AppKit, so extending a selection and typing into it preserve the same range in Java and native code. -On macOS, pure editors receive word movement and deletion, visual line boundaries, document boundaries, -and page movement through `onKeyCommand(...)`. AppKit interprets the key event first. The editor owns +On macOS, pure editors receive word movement and deletion, visual line and paragraph boundaries, +document boundaries, and page movement through `onKeyCommand(...)`. AppKit interprets the key event first. Paragraph boundary commands use `MOD_ALT` with `KEY_HOME` or `KEY_END`. +Paragraph selection uses `MOD_ALT` with `KEY_UP` or `KEY_DOWN` and retains `MOD_SHIFT`. The editor owns its visual line layout; the port waits for its updated state before replaying later native edits. Word commands carry `MOD_ALT`, document Home/End commands carry `MOD_CTRL`, and selection commands also carry `MOD_SHIFT`. These callbacks don't change the legacy `TextArea` editing path. diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java index d0396955b8c..0ab7f4bfa24 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java @@ -181,4 +181,23 @@ void wordDeletionRespectsSelectionUndoAndReadOnly() { assertEquals(" three", v.getText()); } + @FormTest + void paragraphCommandsKeepSelectionAndAdvanceAtBoundaries() { + EditorView v = show("one\ntwo\nthree"); + v.setSelectionRange(5, 5); + v.onKeyCommand(TextInputClient.KEY_HOME, TextInputClient.MOD_ALT); + assertEquals(4, v.getEditingState().getSelectionEnd()); + v.onKeyCommand(TextInputClient.KEY_UP, TextInputClient.MOD_ALT | TextInputClient.MOD_SHIFT); + assertEquals(4, v.getEditingState().getSelectionStart()); + assertEquals(0, v.getEditingState().getSelectionEnd()); + v.setSelectionRange(5, 5); + v.onKeyCommand(TextInputClient.KEY_END, TextInputClient.MOD_ALT); + assertEquals(7, v.getEditingState().getSelectionEnd()); + v.onKeyCommand(TextInputClient.KEY_DOWN, TextInputClient.MOD_ALT | TextInputClient.MOD_SHIFT); + assertEquals(7, v.getEditingState().getSelectionStart()); + assertEquals(13, v.getEditingState().getSelectionEnd()); + v.onKeyCommand(TextInputClient.KEY_DOWN, TextInputClient.MOD_ALT); + assertEquals(13, v.getEditingState().getSelectionEnd()); + } + } From 21633616efbdfecf4fa56799f33fde8257c2e258 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:11:26 +0300 Subject: [PATCH 3/8] Fix editor selection, scrolling and Unicode word deletion semantics --- .../src/com/codename1/ui/TextInputClient.java | 7 + .../src/com/codename1/ui/TextInputState.java | 30 ++- .../com/codename1/ui/editor/EditorView.java | 58 ++++-- .../codename1/ui/editor/WordCharacters.java | 183 ++++++++++++++++++ Ports/MacPort/nativeSources/METALView.m | 8 +- .../codename1/impl/ios/IOSImplementation.java | 4 +- .../Rich-Text-And-Code-Editing.asciidoc | 14 +- .../com/codename1/ui/TextInputStateTest.java | 57 ++++++ .../ui/editor/EditorViewInputTest.java | 80 +++++++- scripts/generate-word-ranges.py | 36 ++++ 10 files changed, 438 insertions(+), 39 deletions(-) create mode 100644 CodenameOne/src/com/codename1/ui/editor/WordCharacters.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/TextInputStateTest.java create mode 100644 scripts/generate-word-ranges.py diff --git a/CodenameOne/src/com/codename1/ui/TextInputClient.java b/CodenameOne/src/com/codename1/ui/TextInputClient.java index c8eebd58135..5901172383f 100644 --- a/CodenameOne/src/com/codename1/ui/TextInputClient.java +++ b/CodenameOne/src/com/codename1/ui/TextInputClient.java @@ -76,6 +76,13 @@ public interface TextInputClient { /// editor with no indentation behavior may treat it as inserting a tab character. int KEY_TAB = 18; + /// Key command: scroll up one viewport without moving the caret or selection. + /// @since 8.0 + int KEY_SCROLL_PAGE_UP = 19; + /// Key command: scroll down one viewport without moving the caret or selection. + /// @since 8.0 + int KEY_SCROLL_PAGE_DOWN = 20; + /// Modifier bit: shift held (used to extend a selection). int MOD_SHIFT = 1; /// Modifier bit: control or command held. diff --git a/CodenameOne/src/com/codename1/ui/TextInputState.java b/CodenameOne/src/com/codename1/ui/TextInputState.java index 04274495955..fe6e6ec847a 100644 --- a/CodenameOne/src/com/codename1/ui/TextInputState.java +++ b/CodenameOne/src/com/codename1/ui/TextInputState.java @@ -33,8 +33,8 @@ /// IME composition is active. public final class TextInputState { private final String text; - private final int selectionStart; - private final int selectionEnd; + private final int selectionAnchor; + private final int selectionFocus; private final int composingStart; private final int composingEnd; @@ -66,8 +66,8 @@ public TextInputState(String text, int selectionStart, int selectionEnd) { /// - `composingEnd`: UTF-16 offset where the marked text ends, or -1 when none public TextInputState(String text, int selectionStart, int selectionEnd, int composingStart, int composingEnd) { this.text = text == null ? "" : text; - this.selectionStart = selectionStart; - this.selectionEnd = selectionEnd; + this.selectionAnchor = selectionStart; + this.selectionFocus = selectionEnd; this.composingStart = composingStart; this.composingEnd = composingEnd; } @@ -77,14 +77,26 @@ public String getText() { return text; } - /// Returns the UTF-16 offset of the selection anchor. + /// Returns the lower UTF-16 selection offset, independent of selection direction. public int getSelectionStart() { - return selectionStart; + return Math.min(selectionAnchor, selectionFocus); } - /// Returns the UTF-16 offset of the selection focus. + /// Returns the upper UTF-16 selection offset, independent of selection direction. public int getSelectionEnd() { - return selectionEnd; + return Math.max(selectionAnchor, selectionFocus); + } + + /// Returns the UTF-16 anchor offset, which may be greater than the focus. + /// @since 8.0 + public int getSelectionAnchor() { + return selectionAnchor; + } + + /// Returns the UTF-16 focus offset (the moving end of a selection). + /// @since 8.0 + public int getSelectionFocus() { + return selectionFocus; } /// Returns the UTF-16 offset where the IME marked text begins, or -1 when no composition is active. @@ -99,7 +111,7 @@ public int getComposingEnd() { /// True when the selection is collapsed to a caret. public boolean isCaret() { - return selectionStart == selectionEnd; + return selectionAnchor == selectionFocus; } /// True when an IME composition (marked text) is currently active. diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 589aba01a9a..2717ebe49a2 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -1745,17 +1745,31 @@ private boolean handleNavKey(int keyCode) { return false; } - private static boolean isWordChar(char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + private int wordCodePointAt(int offset) { + char first = doc.charAt(offset); + if (Character.isHighSurrogate(first) && offset + 1 < doc.length() + && Character.isLowSurrogate(doc.charAt(offset + 1))) { + return Character.toCodePoint(first, doc.charAt(offset + 1)); + } + return first; + } + + private int previousWordOffset(int offset) { + int previous = offset - 1; + if (previous > 0 && Character.isLowSurrogate(doc.charAt(previous)) + && Character.isHighSurrogate(doc.charAt(previous - 1))) { + previous--; + } + return previous; } private int wordLeft() { int p = caret; - while (p > 0 && !isWordChar(doc.charAt(p - 1))) { - p--; + while (p > 0 && !WordCharacters.contains(wordCodePointAt(previousWordOffset(p)))) { + p = previousWordOffset(p); } - while (p > 0 && isWordChar(doc.charAt(p - 1))) { - p--; + while (p > 0 && WordCharacters.contains(wordCodePointAt(previousWordOffset(p)))) { + p = previousWordOffset(p); } return p; } @@ -1763,11 +1777,11 @@ private int wordLeft() { private int wordRight() { int p = caret; int n = doc.length(); - while (p < n && !isWordChar(doc.charAt(p))) { - p++; + while (p < n && !WordCharacters.contains(wordCodePointAt(p))) { + p += Character.charCount(wordCodePointAt(p)); } - while (p < n && isWordChar(doc.charAt(p))) { - p++; + while (p < n && WordCharacters.contains(wordCodePointAt(p))) { + p += Character.charCount(wordCodePointAt(p)); } return p; } @@ -1775,11 +1789,11 @@ private int wordRight() { private void selectWordAt(int offset) { int s = offset; int e = offset; - while (s > 0 && isWordChar(doc.charAt(s - 1))) { - s--; + while (s > 0 && WordCharacters.contains(wordCodePointAt(previousWordOffset(s)))) { + s = previousWordOffset(s); } - while (e < doc.length() && isWordChar(doc.charAt(e))) { - e++; + while (e < doc.length() && WordCharacters.contains(wordCodePointAt(e))) { + e += Character.charCount(wordCodePointAt(e)); } anchor = s; caret = e; @@ -2024,16 +2038,28 @@ public void onKeyCommand(int command, int modifiers) { case KEY_PAGE_DOWN: moveCaret(caretVertical(Math.max(1, contentHeight() / getLineHeight())), extend); break; + case KEY_SCROLL_PAGE_UP: + scrollByPixels(0, Math.max(1, contentHeight())); + break; + case KEY_SCROLL_PAGE_DOWN: + scrollByPixels(0, -Math.max(1, contentHeight())); + break; case KEY_BACKSPACE: if (word && !hasSelection() && editable) { - replaceRange(wordLeft(), caret, "", true); + int start = wordLeft(); + if (start != caret) { + replaceRange(start, caret, "", true); + } } else { deleteBackward(); } break; case KEY_DELETE: if (word && !hasSelection() && editable) { - replaceRange(caret, wordRight(), "", true); + int end = wordRight(); + if (end != caret) { + replaceRange(caret, end, "", true); + } } else { deleteForward(); } diff --git a/CodenameOne/src/com/codename1/ui/editor/WordCharacters.java b/CodenameOne/src/com/codename1/ui/editor/WordCharacters.java new file mode 100644 index 00000000000..d4d877046fa --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/editor/WordCharacters.java @@ -0,0 +1,183 @@ +/* + * 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.editor; + +/** Unicode 16.0.0 letters, marks, decimal digits and underscore. + * Generated by scripts/generate-word-ranges.py; do not edit the ranges manually. + * The core compiles against CLDC, whose Character API lacks Unicode categories. + * These ranges keep classification consistent across ports. This is not dictionary + * segmentation or the UAX 29 word boundary algorithm. + */ +final class WordCharacters { + private WordCharacters() {} + private static final int[] RANGES = { + 0x30, 0x39, 0x41, 0x5a, 0x5f, 0x5f, 0x61, 0x7a, 0xaa, 0xaa, 0xb5, 0xb5, + 0xba, 0xba, 0xc0, 0xd6, 0xd8, 0xf6, 0xf8, 0x2c1, 0x2c6, 0x2d1, 0x2e0, 0x2e4, + 0x2ec, 0x2ec, 0x2ee, 0x2ee, 0x300, 0x374, 0x376, 0x377, 0x37a, 0x37d, 0x37f, 0x37f, + 0x386, 0x386, 0x388, 0x38a, 0x38c, 0x38c, 0x38e, 0x3a1, 0x3a3, 0x3f5, 0x3f7, 0x481, + 0x483, 0x52f, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x591, 0x5bd, 0x5bf, 0x5bf, + 0x5c1, 0x5c2, 0x5c4, 0x5c5, 0x5c7, 0x5c7, 0x5d0, 0x5ea, 0x5ef, 0x5f2, 0x610, 0x61a, + 0x620, 0x669, 0x66e, 0x6d3, 0x6d5, 0x6dc, 0x6df, 0x6e8, 0x6ea, 0x6fc, 0x6ff, 0x6ff, + 0x710, 0x74a, 0x74d, 0x7b1, 0x7c0, 0x7f5, 0x7fa, 0x7fa, 0x7fd, 0x7fd, 0x800, 0x82d, + 0x840, 0x85b, 0x860, 0x86a, 0x870, 0x887, 0x889, 0x88e, 0x897, 0x8e1, 0x8e3, 0x963, + 0x966, 0x96f, 0x971, 0x983, 0x985, 0x98c, 0x98f, 0x990, 0x993, 0x9a8, 0x9aa, 0x9b0, + 0x9b2, 0x9b2, 0x9b6, 0x9b9, 0x9bc, 0x9c4, 0x9c7, 0x9c8, 0x9cb, 0x9ce, 0x9d7, 0x9d7, + 0x9dc, 0x9dd, 0x9df, 0x9e3, 0x9e6, 0x9f1, 0x9fc, 0x9fc, 0x9fe, 0x9fe, 0xa01, 0xa03, + 0xa05, 0xa0a, 0xa0f, 0xa10, 0xa13, 0xa28, 0xa2a, 0xa30, 0xa32, 0xa33, 0xa35, 0xa36, + 0xa38, 0xa39, 0xa3c, 0xa3c, 0xa3e, 0xa42, 0xa47, 0xa48, 0xa4b, 0xa4d, 0xa51, 0xa51, + 0xa59, 0xa5c, 0xa5e, 0xa5e, 0xa66, 0xa75, 0xa81, 0xa83, 0xa85, 0xa8d, 0xa8f, 0xa91, + 0xa93, 0xaa8, 0xaaa, 0xab0, 0xab2, 0xab3, 0xab5, 0xab9, 0xabc, 0xac5, 0xac7, 0xac9, + 0xacb, 0xacd, 0xad0, 0xad0, 0xae0, 0xae3, 0xae6, 0xaef, 0xaf9, 0xaff, 0xb01, 0xb03, + 0xb05, 0xb0c, 0xb0f, 0xb10, 0xb13, 0xb28, 0xb2a, 0xb30, 0xb32, 0xb33, 0xb35, 0xb39, + 0xb3c, 0xb44, 0xb47, 0xb48, 0xb4b, 0xb4d, 0xb55, 0xb57, 0xb5c, 0xb5d, 0xb5f, 0xb63, + 0xb66, 0xb6f, 0xb71, 0xb71, 0xb82, 0xb83, 0xb85, 0xb8a, 0xb8e, 0xb90, 0xb92, 0xb95, + 0xb99, 0xb9a, 0xb9c, 0xb9c, 0xb9e, 0xb9f, 0xba3, 0xba4, 0xba8, 0xbaa, 0xbae, 0xbb9, + 0xbbe, 0xbc2, 0xbc6, 0xbc8, 0xbca, 0xbcd, 0xbd0, 0xbd0, 0xbd7, 0xbd7, 0xbe6, 0xbef, + 0xc00, 0xc0c, 0xc0e, 0xc10, 0xc12, 0xc28, 0xc2a, 0xc39, 0xc3c, 0xc44, 0xc46, 0xc48, + 0xc4a, 0xc4d, 0xc55, 0xc56, 0xc58, 0xc5a, 0xc5d, 0xc5d, 0xc60, 0xc63, 0xc66, 0xc6f, + 0xc80, 0xc83, 0xc85, 0xc8c, 0xc8e, 0xc90, 0xc92, 0xca8, 0xcaa, 0xcb3, 0xcb5, 0xcb9, + 0xcbc, 0xcc4, 0xcc6, 0xcc8, 0xcca, 0xccd, 0xcd5, 0xcd6, 0xcdd, 0xcde, 0xce0, 0xce3, + 0xce6, 0xcef, 0xcf1, 0xcf3, 0xd00, 0xd0c, 0xd0e, 0xd10, 0xd12, 0xd44, 0xd46, 0xd48, + 0xd4a, 0xd4e, 0xd54, 0xd57, 0xd5f, 0xd63, 0xd66, 0xd6f, 0xd7a, 0xd7f, 0xd81, 0xd83, + 0xd85, 0xd96, 0xd9a, 0xdb1, 0xdb3, 0xdbb, 0xdbd, 0xdbd, 0xdc0, 0xdc6, 0xdca, 0xdca, + 0xdcf, 0xdd4, 0xdd6, 0xdd6, 0xdd8, 0xddf, 0xde6, 0xdef, 0xdf2, 0xdf3, 0xe01, 0xe3a, + 0xe40, 0xe4e, 0xe50, 0xe59, 0xe81, 0xe82, 0xe84, 0xe84, 0xe86, 0xe8a, 0xe8c, 0xea3, + 0xea5, 0xea5, 0xea7, 0xebd, 0xec0, 0xec4, 0xec6, 0xec6, 0xec8, 0xece, 0xed0, 0xed9, + 0xedc, 0xedf, 0xf00, 0xf00, 0xf18, 0xf19, 0xf20, 0xf29, 0xf35, 0xf35, 0xf37, 0xf37, + 0xf39, 0xf39, 0xf3e, 0xf47, 0xf49, 0xf6c, 0xf71, 0xf84, 0xf86, 0xf97, 0xf99, 0xfbc, + 0xfc6, 0xfc6, 0x1000, 0x1049, 0x1050, 0x109d, 0x10a0, 0x10c5, 0x10c7, 0x10c7, 0x10cd, 0x10cd, + 0x10d0, 0x10fa, 0x10fc, 0x1248, 0x124a, 0x124d, 0x1250, 0x1256, 0x1258, 0x1258, 0x125a, 0x125d, + 0x1260, 0x1288, 0x128a, 0x128d, 0x1290, 0x12b0, 0x12b2, 0x12b5, 0x12b8, 0x12be, 0x12c0, 0x12c0, + 0x12c2, 0x12c5, 0x12c8, 0x12d6, 0x12d8, 0x1310, 0x1312, 0x1315, 0x1318, 0x135a, 0x135d, 0x135f, + 0x1380, 0x138f, 0x13a0, 0x13f5, 0x13f8, 0x13fd, 0x1401, 0x166c, 0x166f, 0x167f, 0x1681, 0x169a, + 0x16a0, 0x16ea, 0x16f1, 0x16f8, 0x1700, 0x1715, 0x171f, 0x1734, 0x1740, 0x1753, 0x1760, 0x176c, + 0x176e, 0x1770, 0x1772, 0x1773, 0x1780, 0x17d3, 0x17d7, 0x17d7, 0x17dc, 0x17dd, 0x17e0, 0x17e9, + 0x180b, 0x180d, 0x180f, 0x1819, 0x1820, 0x1878, 0x1880, 0x18aa, 0x18b0, 0x18f5, 0x1900, 0x191e, + 0x1920, 0x192b, 0x1930, 0x193b, 0x1946, 0x196d, 0x1970, 0x1974, 0x1980, 0x19ab, 0x19b0, 0x19c9, + 0x19d0, 0x19d9, 0x1a00, 0x1a1b, 0x1a20, 0x1a5e, 0x1a60, 0x1a7c, 0x1a7f, 0x1a89, 0x1a90, 0x1a99, + 0x1aa7, 0x1aa7, 0x1ab0, 0x1ace, 0x1b00, 0x1b4c, 0x1b50, 0x1b59, 0x1b6b, 0x1b73, 0x1b80, 0x1bf3, + 0x1c00, 0x1c37, 0x1c40, 0x1c49, 0x1c4d, 0x1c7d, 0x1c80, 0x1c8a, 0x1c90, 0x1cba, 0x1cbd, 0x1cbf, + 0x1cd0, 0x1cd2, 0x1cd4, 0x1cfa, 0x1d00, 0x1f15, 0x1f18, 0x1f1d, 0x1f20, 0x1f45, 0x1f48, 0x1f4d, + 0x1f50, 0x1f57, 0x1f59, 0x1f59, 0x1f5b, 0x1f5b, 0x1f5d, 0x1f5d, 0x1f5f, 0x1f7d, 0x1f80, 0x1fb4, + 0x1fb6, 0x1fbc, 0x1fbe, 0x1fbe, 0x1fc2, 0x1fc4, 0x1fc6, 0x1fcc, 0x1fd0, 0x1fd3, 0x1fd6, 0x1fdb, + 0x1fe0, 0x1fec, 0x1ff2, 0x1ff4, 0x1ff6, 0x1ffc, 0x2071, 0x2071, 0x207f, 0x207f, 0x2090, 0x209c, + 0x20d0, 0x20f0, 0x2102, 0x2102, 0x2107, 0x2107, 0x210a, 0x2113, 0x2115, 0x2115, 0x2119, 0x211d, + 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, 0x212a, 0x212d, 0x212f, 0x2139, 0x213c, 0x213f, + 0x2145, 0x2149, 0x214e, 0x214e, 0x2183, 0x2184, 0x2c00, 0x2ce4, 0x2ceb, 0x2cf3, 0x2d00, 0x2d25, + 0x2d27, 0x2d27, 0x2d2d, 0x2d2d, 0x2d30, 0x2d67, 0x2d6f, 0x2d6f, 0x2d7f, 0x2d96, 0x2da0, 0x2da6, + 0x2da8, 0x2dae, 0x2db0, 0x2db6, 0x2db8, 0x2dbe, 0x2dc0, 0x2dc6, 0x2dc8, 0x2dce, 0x2dd0, 0x2dd6, + 0x2dd8, 0x2dde, 0x2de0, 0x2dff, 0x2e2f, 0x2e2f, 0x3005, 0x3006, 0x302a, 0x302f, 0x3031, 0x3035, + 0x303b, 0x303c, 0x3041, 0x3096, 0x3099, 0x309a, 0x309d, 0x309f, 0x30a1, 0x30fa, 0x30fc, 0x30ff, + 0x3105, 0x312f, 0x3131, 0x318e, 0x31a0, 0x31bf, 0x31f0, 0x31ff, 0x3400, 0x4dbf, 0x4e00, 0xa48c, + 0xa4d0, 0xa4fd, 0xa500, 0xa60c, 0xa610, 0xa62b, 0xa640, 0xa672, 0xa674, 0xa67d, 0xa67f, 0xa6e5, + 0xa6f0, 0xa6f1, 0xa717, 0xa71f, 0xa722, 0xa788, 0xa78b, 0xa7cd, 0xa7d0, 0xa7d1, 0xa7d3, 0xa7d3, + 0xa7d5, 0xa7dc, 0xa7f2, 0xa827, 0xa82c, 0xa82c, 0xa840, 0xa873, 0xa880, 0xa8c5, 0xa8d0, 0xa8d9, + 0xa8e0, 0xa8f7, 0xa8fb, 0xa8fb, 0xa8fd, 0xa92d, 0xa930, 0xa953, 0xa960, 0xa97c, 0xa980, 0xa9c0, + 0xa9cf, 0xa9d9, 0xa9e0, 0xa9fe, 0xaa00, 0xaa36, 0xaa40, 0xaa4d, 0xaa50, 0xaa59, 0xaa60, 0xaa76, + 0xaa7a, 0xaac2, 0xaadb, 0xaadd, 0xaae0, 0xaaef, 0xaaf2, 0xaaf6, 0xab01, 0xab06, 0xab09, 0xab0e, + 0xab11, 0xab16, 0xab20, 0xab26, 0xab28, 0xab2e, 0xab30, 0xab5a, 0xab5c, 0xab69, 0xab70, 0xabea, + 0xabec, 0xabed, 0xabf0, 0xabf9, 0xac00, 0xd7a3, 0xd7b0, 0xd7c6, 0xd7cb, 0xd7fb, 0xf900, 0xfa6d, + 0xfa70, 0xfad9, 0xfb00, 0xfb06, 0xfb13, 0xfb17, 0xfb1d, 0xfb28, 0xfb2a, 0xfb36, 0xfb38, 0xfb3c, + 0xfb3e, 0xfb3e, 0xfb40, 0xfb41, 0xfb43, 0xfb44, 0xfb46, 0xfbb1, 0xfbd3, 0xfd3d, 0xfd50, 0xfd8f, + 0xfd92, 0xfdc7, 0xfdf0, 0xfdfb, 0xfe00, 0xfe0f, 0xfe20, 0xfe2f, 0xfe70, 0xfe74, 0xfe76, 0xfefc, + 0xff10, 0xff19, 0xff21, 0xff3a, 0xff41, 0xff5a, 0xff66, 0xffbe, 0xffc2, 0xffc7, 0xffca, 0xffcf, + 0xffd2, 0xffd7, 0xffda, 0xffdc, 0x10000, 0x1000b, 0x1000d, 0x10026, 0x10028, 0x1003a, 0x1003c, 0x1003d, + 0x1003f, 0x1004d, 0x10050, 0x1005d, 0x10080, 0x100fa, 0x101fd, 0x101fd, 0x10280, 0x1029c, 0x102a0, 0x102d0, + 0x102e0, 0x102e0, 0x10300, 0x1031f, 0x1032d, 0x10340, 0x10342, 0x10349, 0x10350, 0x1037a, 0x10380, 0x1039d, + 0x103a0, 0x103c3, 0x103c8, 0x103cf, 0x10400, 0x1049d, 0x104a0, 0x104a9, 0x104b0, 0x104d3, 0x104d8, 0x104fb, + 0x10500, 0x10527, 0x10530, 0x10563, 0x10570, 0x1057a, 0x1057c, 0x1058a, 0x1058c, 0x10592, 0x10594, 0x10595, + 0x10597, 0x105a1, 0x105a3, 0x105b1, 0x105b3, 0x105b9, 0x105bb, 0x105bc, 0x105c0, 0x105f3, 0x10600, 0x10736, + 0x10740, 0x10755, 0x10760, 0x10767, 0x10780, 0x10785, 0x10787, 0x107b0, 0x107b2, 0x107ba, 0x10800, 0x10805, + 0x10808, 0x10808, 0x1080a, 0x10835, 0x10837, 0x10838, 0x1083c, 0x1083c, 0x1083f, 0x10855, 0x10860, 0x10876, + 0x10880, 0x1089e, 0x108e0, 0x108f2, 0x108f4, 0x108f5, 0x10900, 0x10915, 0x10920, 0x10939, 0x10980, 0x109b7, + 0x109be, 0x109bf, 0x10a00, 0x10a03, 0x10a05, 0x10a06, 0x10a0c, 0x10a13, 0x10a15, 0x10a17, 0x10a19, 0x10a35, + 0x10a38, 0x10a3a, 0x10a3f, 0x10a3f, 0x10a60, 0x10a7c, 0x10a80, 0x10a9c, 0x10ac0, 0x10ac7, 0x10ac9, 0x10ae6, + 0x10b00, 0x10b35, 0x10b40, 0x10b55, 0x10b60, 0x10b72, 0x10b80, 0x10b91, 0x10c00, 0x10c48, 0x10c80, 0x10cb2, + 0x10cc0, 0x10cf2, 0x10d00, 0x10d27, 0x10d30, 0x10d39, 0x10d40, 0x10d65, 0x10d69, 0x10d6d, 0x10d6f, 0x10d85, + 0x10e80, 0x10ea9, 0x10eab, 0x10eac, 0x10eb0, 0x10eb1, 0x10ec2, 0x10ec4, 0x10efc, 0x10f1c, 0x10f27, 0x10f27, + 0x10f30, 0x10f50, 0x10f70, 0x10f85, 0x10fb0, 0x10fc4, 0x10fe0, 0x10ff6, 0x11000, 0x11046, 0x11066, 0x11075, + 0x1107f, 0x110ba, 0x110c2, 0x110c2, 0x110d0, 0x110e8, 0x110f0, 0x110f9, 0x11100, 0x11134, 0x11136, 0x1113f, + 0x11144, 0x11147, 0x11150, 0x11173, 0x11176, 0x11176, 0x11180, 0x111c4, 0x111c9, 0x111cc, 0x111ce, 0x111da, + 0x111dc, 0x111dc, 0x11200, 0x11211, 0x11213, 0x11237, 0x1123e, 0x11241, 0x11280, 0x11286, 0x11288, 0x11288, + 0x1128a, 0x1128d, 0x1128f, 0x1129d, 0x1129f, 0x112a8, 0x112b0, 0x112ea, 0x112f0, 0x112f9, 0x11300, 0x11303, + 0x11305, 0x1130c, 0x1130f, 0x11310, 0x11313, 0x11328, 0x1132a, 0x11330, 0x11332, 0x11333, 0x11335, 0x11339, + 0x1133b, 0x11344, 0x11347, 0x11348, 0x1134b, 0x1134d, 0x11350, 0x11350, 0x11357, 0x11357, 0x1135d, 0x11363, + 0x11366, 0x1136c, 0x11370, 0x11374, 0x11380, 0x11389, 0x1138b, 0x1138b, 0x1138e, 0x1138e, 0x11390, 0x113b5, + 0x113b7, 0x113c0, 0x113c2, 0x113c2, 0x113c5, 0x113c5, 0x113c7, 0x113ca, 0x113cc, 0x113d3, 0x113e1, 0x113e2, + 0x11400, 0x1144a, 0x11450, 0x11459, 0x1145e, 0x11461, 0x11480, 0x114c5, 0x114c7, 0x114c7, 0x114d0, 0x114d9, + 0x11580, 0x115b5, 0x115b8, 0x115c0, 0x115d8, 0x115dd, 0x11600, 0x11640, 0x11644, 0x11644, 0x11650, 0x11659, + 0x11680, 0x116b8, 0x116c0, 0x116c9, 0x116d0, 0x116e3, 0x11700, 0x1171a, 0x1171d, 0x1172b, 0x11730, 0x11739, + 0x11740, 0x11746, 0x11800, 0x1183a, 0x118a0, 0x118e9, 0x118ff, 0x11906, 0x11909, 0x11909, 0x1190c, 0x11913, + 0x11915, 0x11916, 0x11918, 0x11935, 0x11937, 0x11938, 0x1193b, 0x11943, 0x11950, 0x11959, 0x119a0, 0x119a7, + 0x119aa, 0x119d7, 0x119da, 0x119e1, 0x119e3, 0x119e4, 0x11a00, 0x11a3e, 0x11a47, 0x11a47, 0x11a50, 0x11a99, + 0x11a9d, 0x11a9d, 0x11ab0, 0x11af8, 0x11bc0, 0x11be0, 0x11bf0, 0x11bf9, 0x11c00, 0x11c08, 0x11c0a, 0x11c36, + 0x11c38, 0x11c40, 0x11c50, 0x11c59, 0x11c72, 0x11c8f, 0x11c92, 0x11ca7, 0x11ca9, 0x11cb6, 0x11d00, 0x11d06, + 0x11d08, 0x11d09, 0x11d0b, 0x11d36, 0x11d3a, 0x11d3a, 0x11d3c, 0x11d3d, 0x11d3f, 0x11d47, 0x11d50, 0x11d59, + 0x11d60, 0x11d65, 0x11d67, 0x11d68, 0x11d6a, 0x11d8e, 0x11d90, 0x11d91, 0x11d93, 0x11d98, 0x11da0, 0x11da9, + 0x11ee0, 0x11ef6, 0x11f00, 0x11f10, 0x11f12, 0x11f3a, 0x11f3e, 0x11f42, 0x11f50, 0x11f5a, 0x11fb0, 0x11fb0, + 0x12000, 0x12399, 0x12480, 0x12543, 0x12f90, 0x12ff0, 0x13000, 0x1342f, 0x13440, 0x13455, 0x13460, 0x143fa, + 0x14400, 0x14646, 0x16100, 0x16139, 0x16800, 0x16a38, 0x16a40, 0x16a5e, 0x16a60, 0x16a69, 0x16a70, 0x16abe, + 0x16ac0, 0x16ac9, 0x16ad0, 0x16aed, 0x16af0, 0x16af4, 0x16b00, 0x16b36, 0x16b40, 0x16b43, 0x16b50, 0x16b59, + 0x16b63, 0x16b77, 0x16b7d, 0x16b8f, 0x16d40, 0x16d6c, 0x16d70, 0x16d79, 0x16e40, 0x16e7f, 0x16f00, 0x16f4a, + 0x16f4f, 0x16f87, 0x16f8f, 0x16f9f, 0x16fe0, 0x16fe1, 0x16fe3, 0x16fe4, 0x16ff0, 0x16ff1, 0x17000, 0x187f7, + 0x18800, 0x18cd5, 0x18cff, 0x18d08, 0x1aff0, 0x1aff3, 0x1aff5, 0x1affb, 0x1affd, 0x1affe, 0x1b000, 0x1b122, + 0x1b132, 0x1b132, 0x1b150, 0x1b152, 0x1b155, 0x1b155, 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1bc00, 0x1bc6a, + 0x1bc70, 0x1bc7c, 0x1bc80, 0x1bc88, 0x1bc90, 0x1bc99, 0x1bc9d, 0x1bc9e, 0x1ccf0, 0x1ccf9, 0x1cf00, 0x1cf2d, + 0x1cf30, 0x1cf46, 0x1d165, 0x1d169, 0x1d16d, 0x1d172, 0x1d17b, 0x1d182, 0x1d185, 0x1d18b, 0x1d1aa, 0x1d1ad, + 0x1d242, 0x1d244, 0x1d400, 0x1d454, 0x1d456, 0x1d49c, 0x1d49e, 0x1d49f, 0x1d4a2, 0x1d4a2, 0x1d4a5, 0x1d4a6, + 0x1d4a9, 0x1d4ac, 0x1d4ae, 0x1d4b9, 0x1d4bb, 0x1d4bb, 0x1d4bd, 0x1d4c3, 0x1d4c5, 0x1d505, 0x1d507, 0x1d50a, + 0x1d50d, 0x1d514, 0x1d516, 0x1d51c, 0x1d51e, 0x1d539, 0x1d53b, 0x1d53e, 0x1d540, 0x1d544, 0x1d546, 0x1d546, + 0x1d54a, 0x1d550, 0x1d552, 0x1d6a5, 0x1d6a8, 0x1d6c0, 0x1d6c2, 0x1d6da, 0x1d6dc, 0x1d6fa, 0x1d6fc, 0x1d714, + 0x1d716, 0x1d734, 0x1d736, 0x1d74e, 0x1d750, 0x1d76e, 0x1d770, 0x1d788, 0x1d78a, 0x1d7a8, 0x1d7aa, 0x1d7c2, + 0x1d7c4, 0x1d7cb, 0x1d7ce, 0x1d7ff, 0x1da00, 0x1da36, 0x1da3b, 0x1da6c, 0x1da75, 0x1da75, 0x1da84, 0x1da84, + 0x1da9b, 0x1da9f, 0x1daa1, 0x1daaf, 0x1df00, 0x1df1e, 0x1df25, 0x1df2a, 0x1e000, 0x1e006, 0x1e008, 0x1e018, + 0x1e01b, 0x1e021, 0x1e023, 0x1e024, 0x1e026, 0x1e02a, 0x1e030, 0x1e06d, 0x1e08f, 0x1e08f, 0x1e100, 0x1e12c, + 0x1e130, 0x1e13d, 0x1e140, 0x1e149, 0x1e14e, 0x1e14e, 0x1e290, 0x1e2ae, 0x1e2c0, 0x1e2f9, 0x1e4d0, 0x1e4f9, + 0x1e5d0, 0x1e5fa, 0x1e7e0, 0x1e7e6, 0x1e7e8, 0x1e7eb, 0x1e7ed, 0x1e7ee, 0x1e7f0, 0x1e7fe, 0x1e800, 0x1e8c4, + 0x1e8d0, 0x1e8d6, 0x1e900, 0x1e94b, 0x1e950, 0x1e959, 0x1ee00, 0x1ee03, 0x1ee05, 0x1ee1f, 0x1ee21, 0x1ee22, + 0x1ee24, 0x1ee24, 0x1ee27, 0x1ee27, 0x1ee29, 0x1ee32, 0x1ee34, 0x1ee37, 0x1ee39, 0x1ee39, 0x1ee3b, 0x1ee3b, + 0x1ee42, 0x1ee42, 0x1ee47, 0x1ee47, 0x1ee49, 0x1ee49, 0x1ee4b, 0x1ee4b, 0x1ee4d, 0x1ee4f, 0x1ee51, 0x1ee52, + 0x1ee54, 0x1ee54, 0x1ee57, 0x1ee57, 0x1ee59, 0x1ee59, 0x1ee5b, 0x1ee5b, 0x1ee5d, 0x1ee5d, 0x1ee5f, 0x1ee5f, + 0x1ee61, 0x1ee62, 0x1ee64, 0x1ee64, 0x1ee67, 0x1ee6a, 0x1ee6c, 0x1ee72, 0x1ee74, 0x1ee77, 0x1ee79, 0x1ee7c, + 0x1ee7e, 0x1ee7e, 0x1ee80, 0x1ee89, 0x1ee8b, 0x1ee9b, 0x1eea1, 0x1eea3, 0x1eea5, 0x1eea9, 0x1eeab, 0x1eebb, + 0x1fbf0, 0x1fbf9, 0x20000, 0x2a6df, 0x2a700, 0x2b739, 0x2b740, 0x2b81d, 0x2b820, 0x2cea1, 0x2ceb0, 0x2ebe0, + 0x2ebf0, 0x2ee5d, 0x2f800, 0x2fa1d, 0x30000, 0x3134a, 0x31350, 0x323af, 0xe0100, 0xe01ef + }; + + static boolean contains(int codePoint) { + int low = 0; + int high = RANGES.length / 2 - 1; + while (low <= high) { + int middle = (low + high) >>> 1; + if (codePoint < RANGES[middle * 2]) { + high = middle - 1; + } else if (codePoint > RANGES[middle * 2 + 1]) { + low = middle + 1; + } else { + return true; + } + } + return false; + } +} diff --git a/Ports/MacPort/nativeSources/METALView.m b/Ports/MacPort/nativeSources/METALView.m index 7f1dc8b510b..87937cd1836 100644 --- a/Ports/MacPort/nativeSources/METALView.m +++ b/Ports/MacPort/nativeSources/METALView.m @@ -2231,10 +2231,14 @@ - (void)doCommandBySelector:(SEL)selector { command = 5; modifiers |= 2; // KEY_HOME, MOD_CTRL } else if ([name isEqualToString:@"moveToEndOfDocument:"]) { command = 6; modifiers |= 2; // KEY_END, MOD_CTRL - } else if ([name isEqualToString:@"pageUp:"] || [name isEqualToString:@"scrollPageUp:"]) { + } else if ([name isEqualToString:@"pageUp:"]) { command = 7; - } else if ([name isEqualToString:@"pageDown:"] || [name isEqualToString:@"scrollPageDown:"]) { + } else if ([name isEqualToString:@"pageDown:"]) { command = 8; + } else if ([name isEqualToString:@"scrollPageUp:"]) { + command = 19; // KEY_SCROLL_PAGE_UP: viewport only + } else if ([name isEqualToString:@"scrollPageDown:"]) { + command = 20; // KEY_SCROLL_PAGE_DOWN: viewport only } else if ([name isEqualToString:@"deleteWordBackward:"]) { command = 9; modifiers |= 4; } else if ([name isEqualToString:@"deleteWordForward:"]) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index e4a2a2abdf4..17f1f9a2751 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1894,7 +1894,7 @@ public Object startTextInput(com.codename1.ui.TextInputClient client, com.codena boolean autoCap = config == null || config.isAutoCapitalize(); boolean multiline = config == null || config.isMultiline(); nativeInstance.startTextInput(constraint, autoCorrect, autoCap, multiline, - st.getText(), st.getSelectionStart(), st.getSelectionEnd(), + st.getText(), st.getSelectionAnchor(), st.getSelectionFocus(), config != null ? config.getActionType() : com.codename1.ui.TextInputConfig.ACTION_DEFAULT, captureId); tiBounds = null; refreshTextInputBounds(client); @@ -1913,7 +1913,7 @@ public void updateTextInputState(Object handle, com.codename1.ui.TextInputState int[] r = c != null ? c.getCaretRect() : new int[]{0, 0, 0, 0}; int captureId = queueTextInputCapture(state, c); refreshTextInputBounds(c); - nativeInstance.updateTextInputState(state.getText(), state.getSelectionStart(), state.getSelectionEnd(), + nativeInstance.updateTextInputState(state.getText(), state.getSelectionAnchor(), state.getSelectionFocus(), r[0], r[1], r[2], r[3], tiNativeSeq, captureId); tiKeepNativeCallbacksAlive(); } diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index e4a05bd04d0..3a0b38a0e9a 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -89,17 +89,21 @@ autocorrection and automatic capitalization. The immutable https://www.codenameone.com/javadoc/com/codename1/ui/TextInputState.html[`TextInputState`] returns the authoritative text, selection, and composing range to the port after each edit. -Selection offsets in `TextInputState` are the anchor and focus, in that order. They aren't sorted: -a backward selection can have a larger start offset than end offset. The macOS bridge keeps that -direction alongside the ordered range required by AppKit, so extending a selection and typing -into it preserve the same range in Java and native code. +`TextInputState.getSelectionStart()` and `getSelectionEnd()` return an ordered range for existing +input bridges. `getSelectionAnchor()` and `getSelectionFocus()` retain direction: a backward +selection has an anchor greater than its focus. The Apple bridge uses those directional getters; +UIKit normalizes its native range, while macOS retains the anchor alongside AppKit's ordered range. On macOS, pure editors receive word movement and deletion, visual line and paragraph boundaries, document boundaries, and page movement through `onKeyCommand(...)`. AppKit interprets the key event first. Paragraph boundary commands use `MOD_ALT` with `KEY_HOME` or `KEY_END`. Paragraph selection uses `MOD_ALT` with `KEY_UP` or `KEY_DOWN` and retains `MOD_SHIFT`. The editor owns its visual line layout; the port waits for its updated state before replaying later native edits. Word commands carry `MOD_ALT`, document Home/End commands carry `MOD_CTRL`, and selection commands -also carry `MOD_SHIFT`. These callbacks don't change the legacy `TextArea` editing path. +also carry `MOD_SHIFT`. `KEY_SCROLL_PAGE_UP` and `KEY_SCROLL_PAGE_DOWN` scroll the viewport without changing the caret or +selection; they're distinct from caret-moving `KEY_PAGE_UP` and `KEY_PAGE_DOWN`. Word navigation +classifies Unicode letters, marks, decimal digits and underscore consistently across ports and keeps +surrogate pairs intact. It doesn't perform dictionary segmentation. Boundary word deletions are no-ops. +These callbacks don't change the legacy `TextArea` editing path. All offsets in this contract are UTF-16 indices, matching Java `String`, Android `Editable`, and Apple string indices. The document canonicalizes CRLF and bare CR line endings to LF before it updates the diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/TextInputStateTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/TextInputStateTest.java new file mode 100644 index 00000000000..37996c10b21 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/TextInputStateTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.ui; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class TextInputStateTest { + @Test + void legacyRangeGettersRemainOrderedWhileAppleRetainsDirection() { + TextInputState state = new TextInputState("backward", 8, 0, 1, 3); + assertEquals(0, state.getSelectionStart()); + assertEquals(8, state.getSelectionEnd()); + assertEquals(8, state.getSelectionAnchor()); + assertEquals(0, state.getSelectionFocus()); + assertFalse(state.isCaret()); + assertEquals(1, state.getComposingStart()); + assertEquals(3, state.getComposingEnd()); + // Android deletion clamps end >= start, and DOM selection setters require this order. + assertEquals("backward", state.getText().substring(state.getSelectionStart(), + Math.max(state.getSelectionStart(), state.getSelectionEnd()))); + } + + @Test + void forwardAndCollapsedSnapshotsKeepTheirExistingRange() { + TextInputState forward = new TextInputState("text", 1, 4); + assertEquals(1, forward.getSelectionStart()); + assertEquals(4, forward.getSelectionEnd()); + assertEquals(1, forward.getSelectionAnchor()); + assertEquals(4, forward.getSelectionFocus()); + TextInputState caret = new TextInputState("text", 2, 2); + assertEquals(2, caret.getSelectionStart()); + assertEquals(2, caret.getSelectionEnd()); + assertTrue(caret.isCaret()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java index 0ab7f4bfa24..e9e9a33896e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java @@ -41,7 +41,10 @@ class EditorViewInputTest extends UITestBase { private static final String KANA = "\u306B\u307B\u3093"; private static final String KANJI = "\u65E5\u672C"; + private NoopHost lastHost; + private static final class NoopHost implements EditorHost { + int changes; public boolean isTextInputSupported() { return false; } @@ -57,6 +60,7 @@ public void stopTextInput(Object handle) { } public void editorChanged() { + changes++; } public void fireEditorEvent(String type, String value) { @@ -64,7 +68,8 @@ public void fireEditorEvent(String type, String value) { } private EditorView show(String text) { - EditorView v = new EditorView(new NoopHost(), true); + lastHost = new NoopHost(); + EditorView v = new EditorView(lastHost, true); Form f = new Form("ev", new BorderLayout()); f.add(BorderLayout.CENTER, v); f.show(); @@ -152,8 +157,10 @@ void semanticDocumentCommandsPreserveSelectionDirection() { EditorView v = show("one\ntwo\nthree"); v.onKeyCommand(TextInputClient.KEY_HOME, TextInputClient.MOD_CTRL | TextInputClient.MOD_SHIFT); TextInputState state = v.getEditingState(); - assertEquals(13, state.getSelectionStart()); - assertEquals(0, state.getSelectionEnd()); + assertEquals(0, state.getSelectionStart()); + assertEquals(13, state.getSelectionEnd()); + assertEquals(13, state.getSelectionAnchor()); + assertEquals(0, state.getSelectionFocus()); v.commitText("replacement"); assertEquals("replacement", v.getText()); v.onKeyCommand(TextInputClient.KEY_HOME, TextInputClient.MOD_CTRL); @@ -188,8 +195,10 @@ void paragraphCommandsKeepSelectionAndAdvanceAtBoundaries() { v.onKeyCommand(TextInputClient.KEY_HOME, TextInputClient.MOD_ALT); assertEquals(4, v.getEditingState().getSelectionEnd()); v.onKeyCommand(TextInputClient.KEY_UP, TextInputClient.MOD_ALT | TextInputClient.MOD_SHIFT); - assertEquals(4, v.getEditingState().getSelectionStart()); - assertEquals(0, v.getEditingState().getSelectionEnd()); + assertEquals(0, v.getEditingState().getSelectionStart()); + assertEquals(4, v.getEditingState().getSelectionEnd()); + assertEquals(4, v.getEditingState().getSelectionAnchor()); + assertEquals(0, v.getEditingState().getSelectionFocus()); v.setSelectionRange(5, 5); v.onKeyCommand(TextInputClient.KEY_END, TextInputClient.MOD_ALT); assertEquals(7, v.getEditingState().getSelectionEnd()); @@ -200,4 +209,65 @@ void paragraphCommandsKeepSelectionAndAdvanceAtBoundaries() { assertEquals(13, v.getEditingState().getSelectionEnd()); } + + @FormTest + void wordDeletionUsesUnicodeLettersMarksAndCodePoints() { + String[] words = {"ελληνικά", "العربية", "中文", "हिन्दी", "a\u0301", "\uD801\uDC00\uD801\uDC01"}; + for (String word : words) { + EditorView v = show(word + " " + word); + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + assertEquals(word + " ", v.getText()); + v.performUndo(); + v.setSelectionRange(0, 0); + v.onKeyCommand(TextInputClient.KEY_DELETE, TextInputClient.MOD_ALT); + assertEquals(" " + word, v.getText()); + v.performUndo(); + v.setSelectionRange(0, 0); + v.onKeyCommand(TextInputClient.KEY_RIGHT, TextInputClient.MOD_ALT | TextInputClient.MOD_SHIFT); + assertEquals(word.length(), v.getEditingState().getSelectionFocus()); + } + } + + @FormTest + void emptyWordDeletionPreservesRedoAndDoesNotNotifyHost() { + EditorView v = show("one two"); + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + v.performUndo(); + v.setSelectionRange(0, 0); + int changes = lastHost.changes; + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + assertEquals(changes, lastHost.changes); + v.performRedo(); + assertEquals("one ", v.getText()); + v.performUndo(); + v.setSelectionRange(v.getText().length(), v.getText().length()); + changes = lastHost.changes; + v.onKeyCommand(TextInputClient.KEY_DELETE, TextInputClient.MOD_ALT); + assertEquals(changes, lastHost.changes); + v.performRedo(); + assertEquals("one ", v.getText()); + } + + @FormTest + void scrollPageCommandsKeepCaretSelectionAndUndoHistory() { + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 200; i++) { + text.append("line ").append(i).append('\n'); + } + EditorView v = show(text.toString()); + v.setSelectionRange(2, 0); + TextInputState before = v.getEditingState(); + int changes = lastHost.changes; + assertEquals(0, v.getVerticalScroll()); + v.onKeyCommand(TextInputClient.KEY_SCROLL_PAGE_DOWN, TextInputClient.MOD_SHIFT); + assertTrue(v.getVerticalScroll() > 0); + assertEquals(before.getSelectionAnchor(), v.getEditingState().getSelectionAnchor()); + assertEquals(before.getSelectionFocus(), v.getEditingState().getSelectionFocus()); + assertEquals(text.toString(), v.getText()); + assertEquals(changes, lastHost.changes); + v.onKeyCommand(TextInputClient.KEY_SCROLL_PAGE_UP, 0); + assertEquals(0, v.getVerticalScroll()); + v.onKeyCommand(TextInputClient.KEY_PAGE_DOWN, 0); + assertTrue(v.getEditingState().getSelectionFocus() > 0); + } } diff --git a/scripts/generate-word-ranges.py b/scripts/generate-word-ranges.py new file mode 100644 index 00000000000..dabf79f4093 --- /dev/null +++ b/scripts/generate-word-ranges.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Regenerate the Unicode word-character table for the pure editor. + +Requires Python unicodedata 16.0.0 so regeneration is deterministic. This is +classification for editor word movement, not UAX #29 word/grapheme segmentation. +""" +from pathlib import Path +import unicodedata + +if unicodedata.unidata_version != "16.0.0": + raise RuntimeError("Requires Unicode 16.0.0, got " + unicodedata.unidata_version) + +ranges = [] +for cp in range(0x110000): + category = unicodedata.category(chr(cp)) + if category[0] in ("L", "M") or category == "Nd" or cp == 95: + if ranges and cp == ranges[-1][1] + 1: + ranges[-1][1] = cp + else: + ranges.append([cp, cp]) + +values = [hex(cp) for pair in ranges for cp in pair] +lines = [ + " " + ", ".join(values[i:i + 12]) + + ("," if i + 12 < len(values) else "") + for i in range(0, len(values), 12) +] +root = Path(__file__).resolve().parent.parent +path = root / "CodenameOne/src/com/codename1/ui/editor/WordCharacters.java" +source = path.read_text(encoding="utf-8") +marker = " private static final int[] RANGES = {" +start = source.index(marker) +end = source.index(" };", start) +source = source[:start] + marker + "\n" + "\n".join(lines) + "\n" + source[end:] +path.write_text(source, encoding="utf-8") +print(len(ranges), "ranges") From 7de4cc47d1b2ba6a9dc0179fb73842d4a9d00538 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:37:03 +0300 Subject: [PATCH 4/8] Fix editor focus geometry, spatial word navigation and page layout --- .../src/com/codename1/ui/TextInputClient.java | 7 +- .../com/codename1/ui/TextInputGeometry.java | 6 +- .../src/com/codename1/ui/TextInputState.java | 2 - .../com/codename1/ui/editor/EditorView.java | 102 ++++++++++++++++-- .../codename1/ui/editor/WordCharacters.java | 82 ++++++++++++-- Ports/MacPort/nativeSources/METALView.m | 8 +- .../Rich-Text-And-Code-Editing.asciidoc | 11 +- .../ui/EditorGeometrySnapshotTest.java | 20 ++++ .../ui/editor/EditorViewInputTest.java | 89 +++++++++++++++ scripts/generate-word-ranges.py | 45 ++++---- 10 files changed, 323 insertions(+), 49 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/TextInputClient.java b/CodenameOne/src/com/codename1/ui/TextInputClient.java index 5901172383f..ea6b7d193ef 100644 --- a/CodenameOne/src/com/codename1/ui/TextInputClient.java +++ b/CodenameOne/src/com/codename1/ui/TextInputClient.java @@ -77,12 +77,15 @@ public interface TextInputClient { int KEY_TAB = 18; /// Key command: scroll up one viewport without moving the caret or selection. - /// @since 8.0 int KEY_SCROLL_PAGE_UP = 19; /// Key command: scroll down one viewport without moving the caret or selection. - /// @since 8.0 int KEY_SCROLL_PAGE_DOWN = 20; + /// Key command: move to the next word boundary spatially to the left, including in RTL text. + int KEY_WORD_LEFT = 21; + /// Key command: move to the next word boundary spatially to the right, including in RTL text. + int KEY_WORD_RIGHT = 22; + /// Modifier bit: shift held (used to extend a selection). int MOD_SHIFT = 1; /// Modifier bit: control or command held. diff --git a/CodenameOne/src/com/codename1/ui/TextInputGeometry.java b/CodenameOne/src/com/codename1/ui/TextInputGeometry.java index 98f3b7d52ad..1894d0ff2f2 100644 --- a/CodenameOne/src/com/codename1/ui/TextInputGeometry.java +++ b/CodenameOne/src/com/codename1/ui/TextInputGeometry.java @@ -69,7 +69,7 @@ public static TextInputGeometry capture(TextInputClient client) { return ((Provider) client).getGeometrySnapshot(); } TextInputState state = client.getEditingState(); - int caret = state.getSelectionEnd(); + int caret = state.getSelectionFocus(); int first = caret; int last = caret; if (client instanceof Component) { @@ -100,8 +100,8 @@ public static TextInputGeometry capture(TextInputClient client) { points[n++] = rect[j]; } } - return new TextInputGeometry(points, state.getSelectionStart(), caret, - client.selectionRects(state.getSelectionStart(), caret), client.getCaretRect()); + return new TextInputGeometry(points, state.getSelectionAnchor(), caret, + client.selectionRects(state.getSelectionStart(), state.getSelectionEnd()), client.getCaretRect()); } /// Returns the last captured caret rectangle for an offset, or the active caret offscreen. diff --git a/CodenameOne/src/com/codename1/ui/TextInputState.java b/CodenameOne/src/com/codename1/ui/TextInputState.java index fe6e6ec847a..6b1c44c1b1d 100644 --- a/CodenameOne/src/com/codename1/ui/TextInputState.java +++ b/CodenameOne/src/com/codename1/ui/TextInputState.java @@ -88,13 +88,11 @@ public int getSelectionEnd() { } /// Returns the UTF-16 anchor offset, which may be greater than the focus. - /// @since 8.0 public int getSelectionAnchor() { return selectionAnchor; } /// Returns the UTF-16 focus offset (the moving end of a selection). - /// @since 8.0 public int getSelectionFocus() { return selectionFocus; } diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 2717ebe49a2..6b59fa12802 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -1059,6 +1059,16 @@ private int caretVertical(int delta) { return doc.getLineStart(target) + Math.min(col, lineLen); } + private int caretPage(boolean down) { + int line = doc.lineOfOffset(caret); + Font font = getEditorFont(); + int x = lineContentOffsetX(line) + measureColumnX(line, doc.getLineText(line), + caret - doc.getLineStart(line), font); + int target = lineAtContentY(lineTopContent(line) + (down ? 1 : -1) * Math.max(1, contentHeight())); + return doc.getLineStart(target) + columnAtX(target, doc.getLineText(target), + x - lineContentOffsetX(target), font); + } + private void lineHome(boolean extend) { moveCaret(doc.getLineStart(doc.lineOfOffset(caret)), extend); } @@ -1763,12 +1773,82 @@ private int previousWordOffset(int offset) { return previous; } + private boolean wordCharacterAt(int offset) { + int cp = wordCodePointAt(offset); + while (WordCharacters.isMark(cp)) { + if (offset == 0) { + return false; + } + offset = previousWordOffset(offset); + cp = wordCodePointAt(offset); + } + return WordCharacters.contains(cp); + } + + private int visualWord(boolean right) { + int line = doc.lineOfOffset(caret); + int start = doc.getLineStart(line); + String text = doc.getLineText(line); + Font font = getEditorFont(); + int x = measureColumnX(line, text, caret - start, font); + int best = caret; + int distance = Integer.MAX_VALUE; + // Examine word edges in rendered order, so mixed-direction runs retain + // spatial left/right semantics instead of reversing the whole paragraph. + int[][] runs = bidiRuns(text, font); + for (int[] run : runs) { + int p = run[0]; + while (p < run[1]) { + if (!wordCharacterAt(start + p)) { + p += Character.charCount(wordCodePointAt(start + p)); + continue; + } + int lo = p; + do { + p += Character.charCount(wordCodePointAt(start + p)); + } while (p < run[1] && wordCharacterAt(start + p)); + boolean rtl = (run[2] & 1) != 0; + int edge = right == rtl ? lo : p; + int[] segments = lineVisualSegments(line, text, lo, p, font); + int edgeX = right ? Integer.MIN_VALUE : Integer.MAX_VALUE; + for (int i = 0; i < segments.length; i += 2) { + edgeX = right ? Math.max(edgeX, segments[i] + segments[i + 1]) + : Math.min(edgeX, segments[i]); + } + int delta = right ? edgeX - x : x - edgeX; + if (delta > 0 && delta < distance) { + distance = delta; + best = start + edge; + } + } + } + if (best != caret) { + return best; + } + // A line can end in punctuation or contain no word characters at all. + if (runs.length > 0) { + int[] edgeRun = runs[right ? runs.length - 1 : 0]; + boolean rtl = (edgeRun[2] & 1) != 0; + int edge = start + edgeRun[right == rtl ? 0 : 1]; + if (edge != caret) { + return edge; + } + } + // At the visual line edge continue in the paragraph's reading order. + boolean forward = right != isRTL(); + int adjacent = line + (forward ? 1 : -1); + if (adjacent >= 0 && adjacent < doc.getLineCount()) { + return forward ? doc.getLineStart(adjacent) : doc.getLineEnd(adjacent); + } + return caret; + } + private int wordLeft() { int p = caret; - while (p > 0 && !WordCharacters.contains(wordCodePointAt(previousWordOffset(p)))) { + while (p > 0 && !wordCharacterAt(previousWordOffset(p))) { p = previousWordOffset(p); } - while (p > 0 && WordCharacters.contains(wordCodePointAt(previousWordOffset(p)))) { + while (p > 0 && wordCharacterAt(previousWordOffset(p))) { p = previousWordOffset(p); } return p; @@ -1777,10 +1857,10 @@ private int wordLeft() { private int wordRight() { int p = caret; int n = doc.length(); - while (p < n && !WordCharacters.contains(wordCodePointAt(p))) { + while (p < n && !wordCharacterAt(p)) { p += Character.charCount(wordCodePointAt(p)); } - while (p < n && WordCharacters.contains(wordCodePointAt(p))) { + while (p < n && wordCharacterAt(p)) { p += Character.charCount(wordCodePointAt(p)); } return p; @@ -1789,10 +1869,10 @@ private int wordRight() { private void selectWordAt(int offset) { int s = offset; int e = offset; - while (s > 0 && WordCharacters.contains(wordCodePointAt(previousWordOffset(s)))) { + while (s > 0 && wordCharacterAt(previousWordOffset(s))) { s = previousWordOffset(s); } - while (e < doc.length() && WordCharacters.contains(wordCodePointAt(e))) { + while (e < doc.length() && wordCharacterAt(e)) { e += Character.charCount(wordCodePointAt(e)); } anchor = s; @@ -1990,6 +2070,12 @@ public void onKeyCommand(int command, int modifiers) { boolean lineJump = (modifiers & MOD_CTRL) != 0; boolean word = (modifiers & MOD_ALT) != 0; switch (command) { + case KEY_WORD_LEFT: + moveCaret(visualWord(false), extend); + break; + case KEY_WORD_RIGHT: + moveCaret(visualWord(true), extend); + break; case KEY_LEFT: if (lineJump) { lineHome(extend); @@ -2033,10 +2119,10 @@ public void onKeyCommand(int command, int modifiers) { } break; case KEY_PAGE_UP: - moveCaret(caretVertical(-Math.max(1, contentHeight() / getLineHeight())), extend); + moveCaret(caretPage(false), extend); break; case KEY_PAGE_DOWN: - moveCaret(caretVertical(Math.max(1, contentHeight() / getLineHeight())), extend); + moveCaret(caretPage(true), extend); break; case KEY_SCROLL_PAGE_UP: scrollByPixels(0, Math.max(1, contentHeight())); diff --git a/CodenameOne/src/com/codename1/ui/editor/WordCharacters.java b/CodenameOne/src/com/codename1/ui/editor/WordCharacters.java index d4d877046fa..ffbe9ca4725 100644 --- a/CodenameOne/src/com/codename1/ui/editor/WordCharacters.java +++ b/CodenameOne/src/com/codename1/ui/editor/WordCharacters.java @@ -23,12 +23,11 @@ package com.codename1.ui.editor; -/** Unicode 16.0.0 letters, marks, decimal digits and underscore. - * Generated by scripts/generate-word-ranges.py; do not edit the ranges manually. - * The core compiles against CLDC, whose Character API lacks Unicode categories. - * These ranges keep classification consistent across ports. This is not dictionary - * segmentation or the UAX 29 word boundary algorithm. - */ +/// Unicode 16.0.0 letters, marks, decimal digits and underscore. +/// Generated by scripts/generate-word-ranges.py; do not edit the ranges manually. +/// The core compiles against CLDC, whose Character API lacks Unicode categories. +/// These ranges keep classification consistent across ports. This is not dictionary +/// segmentation or the UAX 29 word boundary algorithm. final class WordCharacters { private WordCharacters() {} private static final int[] RANGES = { @@ -165,14 +164,79 @@ private WordCharacters() {} 0x2ebf0, 0x2ee5d, 0x2f800, 0x2fa1d, 0x30000, 0x3134a, 0x31350, 0x323af, 0xe0100, 0xe01ef }; + private static final int[] MARKS = { + 0x300, 0x36f, 0x483, 0x489, 0x591, 0x5bd, 0x5bf, 0x5bf, 0x5c1, 0x5c2, 0x5c4, 0x5c5, + 0x5c7, 0x5c7, 0x610, 0x61a, 0x64b, 0x65f, 0x670, 0x670, 0x6d6, 0x6dc, 0x6df, 0x6e4, + 0x6e7, 0x6e8, 0x6ea, 0x6ed, 0x711, 0x711, 0x730, 0x74a, 0x7a6, 0x7b0, 0x7eb, 0x7f3, + 0x7fd, 0x7fd, 0x816, 0x819, 0x81b, 0x823, 0x825, 0x827, 0x829, 0x82d, 0x859, 0x85b, + 0x897, 0x89f, 0x8ca, 0x8e1, 0x8e3, 0x903, 0x93a, 0x93c, 0x93e, 0x94f, 0x951, 0x957, + 0x962, 0x963, 0x981, 0x983, 0x9bc, 0x9bc, 0x9be, 0x9c4, 0x9c7, 0x9c8, 0x9cb, 0x9cd, + 0x9d7, 0x9d7, 0x9e2, 0x9e3, 0x9fe, 0x9fe, 0xa01, 0xa03, 0xa3c, 0xa3c, 0xa3e, 0xa42, + 0xa47, 0xa48, 0xa4b, 0xa4d, 0xa51, 0xa51, 0xa70, 0xa71, 0xa75, 0xa75, 0xa81, 0xa83, + 0xabc, 0xabc, 0xabe, 0xac5, 0xac7, 0xac9, 0xacb, 0xacd, 0xae2, 0xae3, 0xafa, 0xaff, + 0xb01, 0xb03, 0xb3c, 0xb3c, 0xb3e, 0xb44, 0xb47, 0xb48, 0xb4b, 0xb4d, 0xb55, 0xb57, + 0xb62, 0xb63, 0xb82, 0xb82, 0xbbe, 0xbc2, 0xbc6, 0xbc8, 0xbca, 0xbcd, 0xbd7, 0xbd7, + 0xc00, 0xc04, 0xc3c, 0xc3c, 0xc3e, 0xc44, 0xc46, 0xc48, 0xc4a, 0xc4d, 0xc55, 0xc56, + 0xc62, 0xc63, 0xc81, 0xc83, 0xcbc, 0xcbc, 0xcbe, 0xcc4, 0xcc6, 0xcc8, 0xcca, 0xccd, + 0xcd5, 0xcd6, 0xce2, 0xce3, 0xcf3, 0xcf3, 0xd00, 0xd03, 0xd3b, 0xd3c, 0xd3e, 0xd44, + 0xd46, 0xd48, 0xd4a, 0xd4d, 0xd57, 0xd57, 0xd62, 0xd63, 0xd81, 0xd83, 0xdca, 0xdca, + 0xdcf, 0xdd4, 0xdd6, 0xdd6, 0xdd8, 0xddf, 0xdf2, 0xdf3, 0xe31, 0xe31, 0xe34, 0xe3a, + 0xe47, 0xe4e, 0xeb1, 0xeb1, 0xeb4, 0xebc, 0xec8, 0xece, 0xf18, 0xf19, 0xf35, 0xf35, + 0xf37, 0xf37, 0xf39, 0xf39, 0xf3e, 0xf3f, 0xf71, 0xf84, 0xf86, 0xf87, 0xf8d, 0xf97, + 0xf99, 0xfbc, 0xfc6, 0xfc6, 0x102b, 0x103e, 0x1056, 0x1059, 0x105e, 0x1060, 0x1062, 0x1064, + 0x1067, 0x106d, 0x1071, 0x1074, 0x1082, 0x108d, 0x108f, 0x108f, 0x109a, 0x109d, 0x135d, 0x135f, + 0x1712, 0x1715, 0x1732, 0x1734, 0x1752, 0x1753, 0x1772, 0x1773, 0x17b4, 0x17d3, 0x17dd, 0x17dd, + 0x180b, 0x180d, 0x180f, 0x180f, 0x1885, 0x1886, 0x18a9, 0x18a9, 0x1920, 0x192b, 0x1930, 0x193b, + 0x1a17, 0x1a1b, 0x1a55, 0x1a5e, 0x1a60, 0x1a7c, 0x1a7f, 0x1a7f, 0x1ab0, 0x1ace, 0x1b00, 0x1b04, + 0x1b34, 0x1b44, 0x1b6b, 0x1b73, 0x1b80, 0x1b82, 0x1ba1, 0x1bad, 0x1be6, 0x1bf3, 0x1c24, 0x1c37, + 0x1cd0, 0x1cd2, 0x1cd4, 0x1ce8, 0x1ced, 0x1ced, 0x1cf4, 0x1cf4, 0x1cf7, 0x1cf9, 0x1dc0, 0x1dff, + 0x20d0, 0x20f0, 0x2cef, 0x2cf1, 0x2d7f, 0x2d7f, 0x2de0, 0x2dff, 0x302a, 0x302f, 0x3099, 0x309a, + 0xa66f, 0xa672, 0xa674, 0xa67d, 0xa69e, 0xa69f, 0xa6f0, 0xa6f1, 0xa802, 0xa802, 0xa806, 0xa806, + 0xa80b, 0xa80b, 0xa823, 0xa827, 0xa82c, 0xa82c, 0xa880, 0xa881, 0xa8b4, 0xa8c5, 0xa8e0, 0xa8f1, + 0xa8ff, 0xa8ff, 0xa926, 0xa92d, 0xa947, 0xa953, 0xa980, 0xa983, 0xa9b3, 0xa9c0, 0xa9e5, 0xa9e5, + 0xaa29, 0xaa36, 0xaa43, 0xaa43, 0xaa4c, 0xaa4d, 0xaa7b, 0xaa7d, 0xaab0, 0xaab0, 0xaab2, 0xaab4, + 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xaac1, 0xaaeb, 0xaaef, 0xaaf5, 0xaaf6, 0xabe3, 0xabea, + 0xabec, 0xabed, 0xfb1e, 0xfb1e, 0xfe00, 0xfe0f, 0xfe20, 0xfe2f, 0x101fd, 0x101fd, 0x102e0, 0x102e0, + 0x10376, 0x1037a, 0x10a01, 0x10a03, 0x10a05, 0x10a06, 0x10a0c, 0x10a0f, 0x10a38, 0x10a3a, 0x10a3f, 0x10a3f, + 0x10ae5, 0x10ae6, 0x10d24, 0x10d27, 0x10d69, 0x10d6d, 0x10eab, 0x10eac, 0x10efc, 0x10eff, 0x10f46, 0x10f50, + 0x10f82, 0x10f85, 0x11000, 0x11002, 0x11038, 0x11046, 0x11070, 0x11070, 0x11073, 0x11074, 0x1107f, 0x11082, + 0x110b0, 0x110ba, 0x110c2, 0x110c2, 0x11100, 0x11102, 0x11127, 0x11134, 0x11145, 0x11146, 0x11173, 0x11173, + 0x11180, 0x11182, 0x111b3, 0x111c0, 0x111c9, 0x111cc, 0x111ce, 0x111cf, 0x1122c, 0x11237, 0x1123e, 0x1123e, + 0x11241, 0x11241, 0x112df, 0x112ea, 0x11300, 0x11303, 0x1133b, 0x1133c, 0x1133e, 0x11344, 0x11347, 0x11348, + 0x1134b, 0x1134d, 0x11357, 0x11357, 0x11362, 0x11363, 0x11366, 0x1136c, 0x11370, 0x11374, 0x113b8, 0x113c0, + 0x113c2, 0x113c2, 0x113c5, 0x113c5, 0x113c7, 0x113ca, 0x113cc, 0x113d0, 0x113d2, 0x113d2, 0x113e1, 0x113e2, + 0x11435, 0x11446, 0x1145e, 0x1145e, 0x114b0, 0x114c3, 0x115af, 0x115b5, 0x115b8, 0x115c0, 0x115dc, 0x115dd, + 0x11630, 0x11640, 0x116ab, 0x116b7, 0x1171d, 0x1172b, 0x1182c, 0x1183a, 0x11930, 0x11935, 0x11937, 0x11938, + 0x1193b, 0x1193e, 0x11940, 0x11940, 0x11942, 0x11943, 0x119d1, 0x119d7, 0x119da, 0x119e0, 0x119e4, 0x119e4, + 0x11a01, 0x11a0a, 0x11a33, 0x11a39, 0x11a3b, 0x11a3e, 0x11a47, 0x11a47, 0x11a51, 0x11a5b, 0x11a8a, 0x11a99, + 0x11c2f, 0x11c36, 0x11c38, 0x11c3f, 0x11c92, 0x11ca7, 0x11ca9, 0x11cb6, 0x11d31, 0x11d36, 0x11d3a, 0x11d3a, + 0x11d3c, 0x11d3d, 0x11d3f, 0x11d45, 0x11d47, 0x11d47, 0x11d8a, 0x11d8e, 0x11d90, 0x11d91, 0x11d93, 0x11d97, + 0x11ef3, 0x11ef6, 0x11f00, 0x11f01, 0x11f03, 0x11f03, 0x11f34, 0x11f3a, 0x11f3e, 0x11f42, 0x11f5a, 0x11f5a, + 0x13440, 0x13440, 0x13447, 0x13455, 0x1611e, 0x1612f, 0x16af0, 0x16af4, 0x16b30, 0x16b36, 0x16f4f, 0x16f4f, + 0x16f51, 0x16f87, 0x16f8f, 0x16f92, 0x16fe4, 0x16fe4, 0x16ff0, 0x16ff1, 0x1bc9d, 0x1bc9e, 0x1cf00, 0x1cf2d, + 0x1cf30, 0x1cf46, 0x1d165, 0x1d169, 0x1d16d, 0x1d172, 0x1d17b, 0x1d182, 0x1d185, 0x1d18b, 0x1d1aa, 0x1d1ad, + 0x1d242, 0x1d244, 0x1da00, 0x1da36, 0x1da3b, 0x1da6c, 0x1da75, 0x1da75, 0x1da84, 0x1da84, 0x1da9b, 0x1da9f, + 0x1daa1, 0x1daaf, 0x1e000, 0x1e006, 0x1e008, 0x1e018, 0x1e01b, 0x1e021, 0x1e023, 0x1e024, 0x1e026, 0x1e02a, + 0x1e08f, 0x1e08f, 0x1e130, 0x1e136, 0x1e2ae, 0x1e2ae, 0x1e2ec, 0x1e2ef, 0x1e4ec, 0x1e4ef, 0x1e5ee, 0x1e5ef, + 0x1e8d0, 0x1e8d6, 0x1e944, 0x1e94a, 0xe0100, 0xe01ef + }; + + static boolean isMark(int codePoint) { + return contains(MARKS, codePoint); + } + static boolean contains(int codePoint) { + return contains(RANGES, codePoint); + } + + private static boolean contains(int[] ranges, int codePoint) { int low = 0; - int high = RANGES.length / 2 - 1; + int high = ranges.length / 2 - 1; while (low <= high) { int middle = (low + high) >>> 1; - if (codePoint < RANGES[middle * 2]) { + if (codePoint < ranges[middle * 2]) { high = middle - 1; - } else if (codePoint > RANGES[middle * 2 + 1]) { + } else if (codePoint > ranges[middle * 2 + 1]) { low = middle + 1; } else { return true; diff --git a/Ports/MacPort/nativeSources/METALView.m b/Ports/MacPort/nativeSources/METALView.m index 87937cd1836..acb41ce32cf 100644 --- a/Ports/MacPort/nativeSources/METALView.m +++ b/Ports/MacPort/nativeSources/METALView.m @@ -2215,9 +2215,13 @@ - (void)doCommandBySelector:(SEL)selector { command = 5; modifiers |= 4; } else if ([name isEqualToString:@"moveToEndOfParagraph:"]) { command = 6; modifiers |= 4; - } else if ([name isEqualToString:@"moveWordLeft:"] || [name isEqualToString:@"moveWordBackward:"]) { + } else if ([name isEqualToString:@"moveWordLeft:"]) { + command = 21; // KEY_WORD_LEFT: spatial direction + } else if ([name isEqualToString:@"moveWordRight:"]) { + command = 22; // KEY_WORD_RIGHT: spatial direction + } else if ([name isEqualToString:@"moveWordBackward:"]) { command = 1; modifiers |= 4; // KEY_LEFT, MOD_ALT - } else if ([name isEqualToString:@"moveWordRight:"] || [name isEqualToString:@"moveWordForward:"]) { + } else if ([name isEqualToString:@"moveWordForward:"]) { command = 2; modifiers |= 4; // KEY_RIGHT, MOD_ALT } else if ([name isEqualToString:@"moveUp:"]) { command = 3; diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index 3a0b38a0e9a..c97e9b8fc31 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -98,11 +98,14 @@ On macOS, pure editors receive word movement and deletion, visual line and parag document boundaries, and page movement through `onKeyCommand(...)`. AppKit interprets the key event first. Paragraph boundary commands use `MOD_ALT` with `KEY_HOME` or `KEY_END`. Paragraph selection uses `MOD_ALT` with `KEY_UP` or `KEY_DOWN` and retains `MOD_SHIFT`. The editor owns its visual line layout; the port waits for its updated state before replaying later native edits. -Word commands carry `MOD_ALT`, document Home/End commands carry `MOD_CTRL`, and selection commands +Logical word commands carry `MOD_ALT`; spatial AppKit word-left/right commands use `KEY_WORD_LEFT` and +`KEY_WORD_RIGHT` and follow the rendered directional runs. Document Home/End commands carry `MOD_CTRL`, and selection commands also carry `MOD_SHIFT`. `KEY_SCROLL_PAGE_UP` and `KEY_SCROLL_PAGE_DOWN` scroll the viewport without changing the caret or -selection; they're distinct from caret-moving `KEY_PAGE_UP` and `KEY_PAGE_DOWN`. Word navigation -classifies Unicode letters, marks, decimal digits and underscore consistently across ports and keeps -surrogate pairs intact. It doesn't perform dictionary segmentation. Boundary word deletions are no-ops. +selection; they're distinct from caret-moving `KEY_PAGE_UP` and `KEY_PAGE_DOWN`, which use cumulative +line heights and visual columns for rich content. Fallback geometry snapshots sample around the active +selection focus, including backward selections. Word navigation classifies Unicode letters, decimal +digits and underscore consistently across ports. Combining marks inherit their base character's +classification, so emoji variation selectors stay attached during word deletion. It keeps surrogate pairs intact. It doesn't perform dictionary segmentation. Boundary word deletions are no-ops. These callbacks don't change the legacy `TextArea` editing path. All offsets in this contract are UTF-16 indices, matching Java `String`, Android `Editable`, and Apple diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java index ef7e5d49750..fe6edb29b13 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java @@ -190,4 +190,24 @@ void fallbackSamplesViewportAfterOwnAndAncestorScrolling() { assertArrayEquals(new int[]{150, 145}, client.samples.get(3)); } + @FormTest + void fallbackCapsGeometryAroundBackwardSelectionFocus() { + final int[] range = {-1, -1}; + ScrolledClient client = new ScrolledClient() { + @Override public TextInputState getEditingState() { return new TextInputState("", 9000, 100); } + @Override public int getTextLength() { return 10000; } + @Override public int offsetAtPoint(int x, int y) { return y == 0 ? 0 : 9999; } + @Override public int[] rectForOffset(int offset) { return new int[]{offset, 0, 1, 20}; } + @Override public int[] getCaretRect() { return new int[]{-1, -1, 1, 20}; } + @Override public int[] selectionRects(int a, int b) { range[0] = a; range[1] = b; return new int[0]; } + }; + client.setWidth(100); + client.setHeight(100); + TextInputGeometry geometry = TextInputGeometry.capture(client); + assertArrayEquals(new int[]{100, 0, 1, 20}, geometry.rectForOffset(100)); + assertArrayEquals(new int[]{-1, -1, 1, 20}, geometry.rectForOffset(9000)); + assertEquals(100, geometry.offsetAtPoint(100, 0)); + assertArrayEquals(new int[]{100, 9000}, range); + } + } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java index e9e9a33896e..9d4920423a9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java @@ -270,4 +270,93 @@ void scrollPageCommandsKeepCaretSelectionAndUndoHistory() { v.onKeyCommand(TextInputClient.KEY_PAGE_DOWN, 0); assertTrue(v.getEditingState().getSelectionFocus() > 0); } + @FormTest + void variationSelectorsFollowTheirBaseDuringWordDeletion() { + for (String symbol : new String[]{"\u2764\uFE0F", "\u2600\uFE0E", "\uD83D\uDE00\uDB40\uDD00"}) { + EditorView v = show(symbol); + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + assertEquals("", v.getText()); + v.performUndo(); + assertEquals(symbol, v.getText()); + v.setSelectionRange(0, 0); + v.onKeyCommand(TextInputClient.KEY_DELETE, TextInputClient.MOD_ALT); + assertEquals("", v.getText()); + } + EditorView v = show("a\u0301 b\u0301"); + v.onKeyCommand(TextInputClient.KEY_BACKSPACE, TextInputClient.MOD_ALT); + assertEquals("a\u0301 ", v.getText()); + } + + @FormTest + void spatialWordCommandsRespectRtlAndMixedRuns() { + for (EditorView v : new EditorView[]{new EditorView(new NoopHost(), true), new RichView(new NoopHost())}) { + Form form = new Form("bidi", new BorderLayout()); + form.add(BorderLayout.CENTER, v); + form.show(); + flushSerialCalls(); + v.setText("אבג דהו"); + if (v instanceof RichView) { + v.setSelectionRange(0, 3); + ((RichView) v).toggleBold(); + } + v.setRTL(true); + v.setSelectionRange(0, 0); + v.onKeyCommand(TextInputClient.KEY_WORD_LEFT, TextInputClient.MOD_SHIFT); + assertEquals(0, v.getEditingState().getSelectionAnchor()); + assertEquals(3, v.getEditingState().getSelectionFocus()); + v.onKeyCommand(TextInputClient.KEY_WORD_RIGHT, 0); + assertEquals(0, v.getEditingState().getSelectionFocus()); + v.setSelectionRange(2, 2); + v.onKeyCommand(TextInputClient.KEY_LEFT, TextInputClient.MOD_ALT); + assertEquals(0, v.getEditingState().getSelectionFocus(), "logical backward still decreases offsets"); + v.setText("abc אבג xyz"); + if (v instanceof RichView) { + v.setSelectionRange(0, 3); + ((RichView) v).toggleBold(); + } + v.setRTL(false); + v.setSelectionRange(5, 5); + v.onKeyCommand(TextInputClient.KEY_WORD_LEFT, 0); + assertEquals(7, v.getEditingState().getSelectionFocus(), "spatial left within the RTL run"); + v.setSelectionRange(5, 5); + v.onKeyCommand(TextInputClient.KEY_WORD_RIGHT, 0); + assertEquals(4, v.getEditingState().getSelectionFocus(), "spatial right within the RTL run"); + v.setSelectionRange(1, 1); + v.onKeyCommand(TextInputClient.KEY_WORD_RIGHT, 0); + assertEquals(3, v.getEditingState().getSelectionFocus()); + v.setText("abc !!!"); + v.setSelectionRange(3, 3); + v.onKeyCommand(TextInputClient.KEY_WORD_RIGHT, 0); + assertEquals(7, v.getEditingState().getSelectionFocus()); + v.setText("\u2764\uFE0F"); + v.setSelectionRange(0, 0); + v.onKeyCommand(TextInputClient.KEY_WORD_RIGHT, 0); + assertEquals(2, v.getEditingState().getSelectionFocus()); + v.onKeyCommand(TextInputClient.KEY_WORD_LEFT, 0); + assertEquals(0, v.getEditingState().getSelectionFocus()); + } + } + + @FormTest + void pageCommandsUseActualLineHeightsAndVisualColumn() { + EditorView v = new EditorView(new NoopHost(), true) { + @Override protected boolean uniformLineHeight() { return false; } + @Override protected int lineHeightAt(int line) { return line == 1 ? 160 : 20; } + }; + Form f = new Form("page", new BorderLayout()); + f.add(BorderLayout.CENTER, v); + f.show(); + flushSerialCalls(); + v.getAllStyles().setPadding(0, 0, 0, 0); + v.setHeight(100); + v.setText("abc\nabc\nabc\nabc\nabc\nabc\nabc\nabc"); + v.setSelectionRange(1, 1); + v.onKeyCommand(TextInputClient.KEY_PAGE_DOWN, TextInputClient.MOD_SHIFT); + assertEquals(1, v.getEditingState().getSelectionAnchor()); + assertEquals(5, v.getEditingState().getSelectionFocus(), "one page lands in the tall second line"); + v.setSelectionRange(13, 13); + v.onKeyCommand(TextInputClient.KEY_PAGE_UP, 0); + assertEquals(5, v.getEditingState().getSelectionFocus(), "up also uses cumulative line pixels"); + } + } diff --git a/scripts/generate-word-ranges.py b/scripts/generate-word-ranges.py index dabf79f4093..9c17b51572b 100644 --- a/scripts/generate-word-ranges.py +++ b/scripts/generate-word-ranges.py @@ -10,27 +10,34 @@ if unicodedata.unidata_version != "16.0.0": raise RuntimeError("Requires Unicode 16.0.0, got " + unicodedata.unidata_version) -ranges = [] -for cp in range(0x110000): - category = unicodedata.category(chr(cp)) - if category[0] in ("L", "M") or category == "Nd" or cp == 95: - if ranges and cp == ranges[-1][1] + 1: - ranges[-1][1] = cp - else: - ranges.append([cp, cp]) +def generate_ranges(predicate): + ranges = [] + for cp in range(0x110000): + if predicate(cp, unicodedata.category(chr(cp))): + if ranges and cp == ranges[-1][1] + 1: + ranges[-1][1] = cp + else: + ranges.append([cp, cp]) + return ranges + -values = [hex(cp) for pair in ranges for cp in pair] -lines = [ - " " + ", ".join(values[i:i + 12]) - + ("," if i + 12 < len(values) else "") - for i in range(0, len(values), 12) -] root = Path(__file__).resolve().parent.parent path = root / "CodenameOne/src/com/codename1/ui/editor/WordCharacters.java" source = path.read_text(encoding="utf-8") -marker = " private static final int[] RANGES = {" -start = source.index(marker) -end = source.index(" };", start) -source = source[:start] + marker + "\n" + "\n".join(lines) + "\n" + source[end:] +for name, predicate in [ + ("RANGES", lambda cp, category: category[0] in ("L", "M") or category == "Nd" or cp == 95), + ("MARKS", lambda cp, category: category[0] == "M"), +]: + ranges = generate_ranges(predicate) + values = [hex(cp) for pair in ranges for cp in pair] + lines = [ + " " + ", ".join(values[i:i + 12]) + + ("," if i + 12 < len(values) else "") + for i in range(0, len(values), 12) + ] + marker = " private static final int[] " + name + " = {" + start = source.index(marker) + end = source.index(" };", start) + source = source[:start] + marker + "\n" + "\n".join(lines) + "\n" + source[end:] + print(name, len(ranges), "ranges") path.write_text(source, encoding="utf-8") -print(len(ranges), "ranges") From 58f3948e4ad7bca874b52a3d0328de4faaa1632e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:15:04 +0300 Subject: [PATCH 5/8] Preserve native grapheme movement and reset Select All anchor --- Ports/MacPort/nativeSources/METALView.m | 13 ++-- .../Rich-Text-And-Code-Editing.asciidoc | 3 + scripts/macos/editor-command-tests/check.py | 13 +++- scripts/macos/editor-command-tests/contract.m | 73 +++++++++++++++++-- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/Ports/MacPort/nativeSources/METALView.m b/Ports/MacPort/nativeSources/METALView.m index acb41ce32cf..18102a8363c 100644 --- a/Ports/MacPort/nativeSources/METALView.m +++ b/Ports/MacPort/nativeSources/METALView.m @@ -2203,11 +2203,8 @@ - (void)doCommandBySelector:(SEL)selector { modifiers |= 1; // TextInputClient.MOD_SHIFT name = [[name substringToIndex:name.length - suffix.length] stringByAppendingString:@":"]; } - if ([name isEqualToString:@"moveBackward:"]) { - command = 1; - } else if ([name isEqualToString:@"moveForward:"]) { - command = 2; - } else if ([name isEqualToString:@"moveParagraphBackward:"]) { + // Character commands retain the composed-character native path below. + if ([name isEqualToString:@"moveParagraphBackward:"]) { command = 3; modifiers |= 4; } else if ([name isEqualToString:@"moveParagraphForward:"]) { command = 4; modifiers |= 4; @@ -2325,14 +2322,14 @@ extern JAVA_VOID com_codename1_impl_mac_MacImplementation_macTraverseTextEditing // pressing an arrow twice from offset 5 gave [4,5) and then 4 rather than // [3,5). A plain arrow genuinely ends the selection gesture and still // forgets; an extending one must not. - if (selector == @selector(moveLeft:)) { + if (selector == @selector(moveLeft:) || selector == @selector(moveBackward:)) { NSUInteger at = sel.length > 0 ? sel.location : [self cn1IndexBefore:sel.location inText:session.text]; [self cn1SetSelection:NSMakeRange(at, 0)]; [self cn1ForgetAnchor]; return; } - if (selector == @selector(moveRight:)) { + if (selector == @selector(moveRight:) || selector == @selector(moveForward:)) { NSUInteger at = sel.length > 0 ? NSMaxRange(sel) : [self cn1IndexAfter:sel.location inText:session.text]; [self cn1SetSelection:NSMakeRange(at, 0)]; @@ -2444,6 +2441,7 @@ extern JAVA_VOID com_codename1_impl_mac_MacImplementation_macTraverseTextEditing return; } if (selector == @selector(selectAll:)) { + [self cn1RememberAnchor:0]; [self cn1SetSelection:NSMakeRange(0, len)]; return; } @@ -2794,6 +2792,7 @@ - (void)selectAll:(id)sender { // Edit menu land here directly rather than through doCommandBySelector, so // assigning the shadow alone left the Java editor with no selection to // render -- and a following Cut or Copy then acted on nothing. + [self cn1RememberAnchor:0]; [self cn1SetSelection:NSMakeRange(0, session.text.length)]; } diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index c97e9b8fc31..2939dac17f3 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -106,6 +106,9 @@ line heights and visual columns for rich content. Fallback geometry snapshots sa selection focus, including backward selections. Word navigation classifies Unicode letters, decimal digits and underscore consistently across ports. Combining marks inherit their base character's classification, so emoji variation selectors stay attached during word deletion. It keeps surrogate pairs intact. It doesn't perform dictionary segmentation. Boundary word deletions are no-ops. +Character movement remains on the native composed-character path so accents, emoji variation +selectors and ZWJ sequences stay intact. Unmodified character movement collapses a selection to its +ordered edge. Select All explicitly creates a forward selection before later shift-arrow commands. These callbacks don't change the legacy `TextArea` editing path. All offsets in this contract are UTF-16 indices, matching Java `String`, Android `Editable`, and Apple diff --git a/scripts/macos/editor-command-tests/check.py b/scripts/macos/editor-command-tests/check.py index 9a531958db7..896c231b30a 100644 --- a/scripts/macos/editor-command-tests/check.py +++ b/scripts/macos/editor-command-tests/check.py @@ -22,11 +22,20 @@ def method(source, signature): "- (void)resetDeferredEditsIfNeeded", "- (BOOL)deferNativeEdit:", "- (int)beginEditorCommand", "- (void)completeEditorCommand:"])) (tmp / "echo.h").write_text(method(session, - "JAVA_VOID com_codename1_impl_ios_IOSNative_updateTextInputState___")) + "JAVA_VOID com_codename1_impl_ios_IOSNative_updateTextInputState___") + + method(session, "void CN1MacTextInputNotifySelection(") + + method(view, "static NSUInteger cn1ActiveEdge(")) (tmp / "view.h").write_text("".join(method(view, signature) for signature in [ "- (BOOL)cn1DeferNativeEdit:", "- (void)cn1SendEditorKeyCommand:(int)command modifiers:", "- (void)cn1SendEditorKeyCommand:(int)command {", "- (NSInteger)cn1ReplaceRange:", - "- (NSRange)cn1EffectiveRange:", "- (void)insertText:", "- (void)cut:", "- (void)paste:"])) + "- (NSRange)cn1EffectiveRange:", "- (void)insertText:", "- (void)cut:", "- (void)paste:", + "- (NSUInteger)cn1LineStart:", "- (NSUInteger)cn1LineEnd:", + "- (NSUInteger)cn1AfterLineBreak:", "- (NSUInteger)cn1BeforeLineBreak:", + "- (NSUInteger)cn1VerticalTarget:", "- (NSUInteger)cn1SelectionAnchor:", + "- (void)cn1RememberAnchor:", "- (NSUInteger)cn1IndexBefore:", + "- (NSUInteger)cn1IndexAfter:", "- (NSRange)cn1Extend:", + "- (void)cn1ForgetAnchor", "- (void)cn1SetSelection:", + "- (void)doCommandBySelector:", "- (void)selectAll:"])) executable = tmp / "contract" subprocess.run(["xcrun", "clang", "-fno-objc-arc", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-framework", "AppKit", "-I", str(tmp), diff --git a/scripts/macos/editor-command-tests/contract.m b/scripts/macos/editor-command-tests/contract.m index 12c1183d1ff..4fe85a5fd02 100644 --- a/scripts/macos/editor-command-tests/contract.m +++ b/scripts/macos/editor-command-tests/contract.m @@ -25,6 +25,9 @@ #define JAVA_VOID void #define JAVA_INT int +#define JAVA_BOOLEAN int +#define JAVA_TRUE 1 +#define JAVA_FALSE 0 #define JAVA_OBJECT id #define JAVA_NULL nil #define CODENAME_ONE_THREAD_STATE void *thread @@ -45,7 +48,7 @@ @interface CN1MacTextInputSession : NSObject { NSMutableArray *deferredEdits; } @property BOOL active, pureEditor, multiline; -@property NSUInteger maxSize; +@property NSUInteger maxSize, selectionAnchor; @property(copy) NSString *text; @property NSRange selectedRange, markedRange; @property CGRect caretRect; @@ -70,6 +73,7 @@ - (void)reset:(NSString *)text { self.active = YES; self.pureEditor = YES; self.multiline = YES; self.text = text; self.selectedRange = NSMakeRange(1, 2); self.markedRange = NSMakeRange(NSNotFound, 0); + self.selectionAnchor = self.selectedRange.location; [self resetDeferredEditsIfNeeded]; } - (void)commitFinished:(BOOL)finished { } @@ -90,9 +94,21 @@ void com_codename1_impl_ios_IOSImplementation_tiKeyCommandWithSequence___int_int void *thread, int cmd, int modifiers, int sequence) { command = cmd; commandSequence = sequence; commandCount++; } +static int reportedAnchor, reportedFocus; +void com_codename1_impl_ios_IOSImplementation_tiSetSelection___int_int_int( + void *thread, int anchor, int focus, int sequence) { + reportedAnchor = anchor; reportedFocus = focus; +} +void com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean( + void *thread, int captureId, int accepted) { } +void com_codename1_impl_mac_MacImplementation_macTraverseTextEditing___boolean( + void *thread, int forward) { } #include "echo.h" -@interface TestView : NSObject +@interface TestView : NSResponder { + NSUInteger selectionAnchor; + BOOL selectionAnchorValid; +} - (BOOL)cn1DeferNativeEdit:(void (^)(void))edit; - (void)cn1SendEditorKeyCommand:(int)command modifiers:(int)modifiers; - (void)cn1SendEditorKeyCommand:(int)command; @@ -105,7 +121,6 @@ - (void)paste:(id)sender; - (void)insertText:(id)string replacementRange:(NSRange)range; @end @implementation TestView -- (void)cn1ForgetAnchor { } - (NSRange)cn1SelectionForEditing { return [CN1MacTextInputSession sharedSession].selectedRange; } - (void)copy:(id)sender { } - (void)delete:(id)sender { } @@ -119,8 +134,8 @@ static void drain(void) { } } static void echo(NSString *text, int caret, int sequence) { - com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int( - NULL, nil, text, caret, caret, 0, 0, 1, 20, sequence); + com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int_int( + NULL, nil, text, caret, caret, 0, 0, 1, 20, sequence, 0); } int main(void) { @autoreleasepool { @@ -162,7 +177,53 @@ int main(void) { assert([session.text isEqualToString:@"new document"]); [view insertText:@"X" replacementRange:NSMakeRange(NSNotFound, 0)]; assert([session.text isEqualToString:@"nX document"]); - puts("MAC_EDITOR_COMMAND_ORDER_OK"); + for (NSNumber *pure in @[@YES, @NO]) { + for (NSString *grapheme in @[@"e\u0301", @"\u2764\ufe0f", @"👩‍💻", @"🇮🇱"]) { + NSString *text = [@"A" stringByAppendingString:[grapheme stringByAppendingString:@"B"]]; + [session reset:text]; session.pureEditor = pure.boolValue; + NSUInteger end = 1 + grapheme.length; + session.selectedRange = NSMakeRange(end, 0); + session.selectionAnchor = end; + [view cn1ForgetAnchor]; + [view doCommandBySelector:@selector(moveBackward:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(1, 0))); + [view doCommandBySelector:@selector(moveForward:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(end, 0))); + [view doCommandBySelector:@selector(moveBackwardAndModifySelection:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(1, grapheme.length))); + [view doCommandBySelector:@selector(moveForwardAndModifySelection:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(end, 0))); + session.selectedRange = NSMakeRange(1, 0); session.selectionAnchor = 1; + [view cn1ForgetAnchor]; + [view doCommandBySelector:@selector(moveForwardAndModifySelection:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(1, grapheme.length))); + [view insertText:@"X" replacementRange:NSMakeRange(NSNotFound, 0)]; + assert([session.text isEqualToString:@"AXB"]); + } + for (NSNumber *backward in @[@YES, @NO]) { + for (NSNumber *forward in @[@YES, @NO]) { + [session reset:@"abcdefgh"]; session.pureEditor = pure.boolValue; + session.selectedRange = NSMakeRange(2, 3); + session.selectionAnchor = backward.boolValue ? 5 : 2; + [view cn1ForgetAnchor]; + [view doCommandBySelector:forward.boolValue ? @selector(moveForward:) : @selector(moveBackward:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(forward.boolValue ? 5 : 2, 0))); + } + } + } + for (NSNumber *menu in @[@YES, @NO]) { + [session reset:@"abc"]; + session.selectedRange = NSMakeRange(3, 0); session.selectionAnchor = 3; + if (menu.boolValue) { [view selectAll:nil]; } + else { [view doCommandBySelector:@selector(selectAll:)]; } + assert(reportedAnchor == 0 && reportedFocus == 3); + [view doCommandBySelector:@selector(moveForwardAndModifySelection:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(0, 3))); + [view doCommandBySelector:@selector(moveBackwardAndModifySelection:)]; + assert(NSEqualRanges(session.selectedRange, NSMakeRange(0, 2))); + assert(reportedAnchor == 0 && reportedFocus == 2); + } + puts("MAC_EDITOR_COMMAND_ORDER_AND_SELECTION_OK"); } return 0; } From f13d73f57320b49104731777570e43ef787b0a01 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:32:00 +0300 Subject: [PATCH 6/8] Preserve active selection focus in editor geometry snapshots --- .../com/codename1/ui/editor/EditorView.java | 2 +- .../ui/EditorGeometrySnapshotTest.java | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 6b59fa12802..f02e6aad2ce 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -2281,7 +2281,7 @@ public com.codename1.ui.TextInputGeometry getGeometrySnapshot() { System.arraycopy(line, 0, points, at, line.length); at += line.length; } - return new com.codename1.ui.TextInputGeometry(points, getSelectionStart(), getSelectionEnd(), + return new com.codename1.ui.TextInputGeometry(points, anchor < 0 ? caret : anchor, caret, selectionRects(getSelectionStart(), getSelectionEnd(), first, last), getCaretRect()); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java index fe6edb29b13..5f0349ac390 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java @@ -210,4 +210,33 @@ void fallbackCapsGeometryAroundBackwardSelectionFocus() { assertArrayEquals(new int[]{100, 9000}, range); } + @FormTest + void providerWithoutVisibleCaretPointsRetainsSelectionFocus() { + class ScrolledEditor extends EditorView { + final CountingFont font = new CountingFont(); + ScrolledEditor() { super(null, false); } + @Override protected Font getEditorFont() { return font; } + void hideVisibleShortLines() { + scrollByPixels(-500, -200); + assertTrue(getHorizontalScroll() >= 500); + assertTrue(getVerticalScroll() >= 200); + } + } + ScrolledEditor editor = new ScrolledEditor(); + editor.setWidth(200); + editor.setHeight(80); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 2000; i++) { text.append('x'); } + for (int i = 0; i < 100; i++) { text.append("\nabc"); } + editor.setText(text.toString()); + for (int[] selection : new int[][]{{2004, 2001}, {2001, 2004}, {2002, 2002}}) { + editor.setSelectionRange(selection[0], selection[1]); + editor.hideVisibleShortLines(); + TextInputGeometry geometry = TextInputGeometry.capture(editor); + assertEquals(selection[1], geometry.offsetAtPoint(0, 0)); + assertEquals(selection[1], geometry.offsetAtPoint(150, 60)); + assertArrayEquals(editor.getCaretRect(), geometry.rectForOffset(0)); + } + } + } From 895fa940026a4f51d8250288ee729674665a45c4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:10:22 +0300 Subject: [PATCH 7/8] Make popup width tests independent of natural combo size --- .../codename1/ui/ComboBoxPopupWidthTest.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupWidthTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupWidthTest.java index b6d9871c052..d2c0c2b471a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupWidthTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/ComboBoxPopupWidthTest.java @@ -28,6 +28,9 @@ import com.codename1.ui.ComboBoxPopupTestSupport.ScriptedComboBox; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import java.util.Hashtable; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -39,6 +42,7 @@ private static final class Widths implements PopupScript { int popupWidth; int popupLeft; int sideGap; + int listPreferredWidth; @Override public void run(Dialog popup, List list) { @@ -47,10 +51,19 @@ public void run(Dialog popup, List list) { popupWidth = box.getWidth(); popupLeft = box.getAbsoluteX(); sideGap = list.getSideGap(); + listPreferredWidth = list.getPreferredW(); } } private ScriptedComboBox comboOnForm(String... items) { + // A wide desktop gutter exposes the assumption that a naturally sized combo + // is always wider than its popup rows. Fonts and themes can make either wider. + Hashtable theme = new Hashtable(); + theme.put("@interactiveScrollBool", "true"); + theme.put("DesktopScroll.padding", "0,0,24,24"); + theme.put("DesktopScroll.padUnit", new byte[]{Style.UNIT_TYPE_PIXELS, + Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS}); + UIManager.getInstance().addThemeProps(theme); Form form = new Form("combo", new BorderLayout()); ScriptedComboBox combo = new ScriptedComboBox(items) { @Override @@ -76,10 +89,15 @@ protected List createPopupList() { void aDesktopPopupIsAsWideAsItsCombo() throws Exception { implementation.setDesktop(true); ScriptedComboBox combo = comboOnForm("One", "Two", "Three"); + combo.setPreferredW(200); + combo.getComponentForm().revalidate(); + DisplayTest.flushEdt(); Widths w = new Widths(); ComboBoxPopupTestSupport.open(combo, w); - assertTrue(w.sideGap > 0, "the case needs a scrollbar gutter to mean anything"); + assertTrue(w.sideGap >= 48, "the fixture must reserve its wide scrollbar gutter"); + assertTrue(w.listPreferredWidth < combo.getWidth(), + "this case needs the combo to be wider than the rows including their gutter"); assertEquals(combo.getWidth(), w.popupWidth, "the popup lines up with both edges of the combo"); assertEquals(combo.getAbsoluteX(), w.popupLeft); } @@ -95,7 +113,11 @@ void aPopupWithWiderContentStillGrows() throws Exception { ComboBoxPopupTestSupport.open(combo, w); assertTrue(combo.getWidth() <= 40); + assertTrue(w.listPreferredWidth > combo.getWidth(), + "this case needs the rows including their gutter to be wider than the combo"); assertTrue(w.popupWidth > combo.getWidth(), "rows wider than the combo are not clipped to it"); + assertEquals(w.listPreferredWidth, w.popupWidth, + "the scrollbar gutter is already included in the rows' preferred width"); } } From 96bd8857d945b45af5c924406956bb4d89e771ed Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:10:31 +0300 Subject: [PATCH 8/8] Recalibrate Linux translation baselines from matching CPU reports --- .../perf-baseline/evidence/pr-6027-linux.md | 50 +++++++++++++++++++ vm/selfhost/perf-baseline/pr/6027.json | 39 +++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 vm/selfhost/perf-baseline/evidence/pr-6027-linux.md create mode 100644 vm/selfhost/perf-baseline/pr/6027.json diff --git a/vm/selfhost/perf-baseline/evidence/pr-6027-linux.md b/vm/selfhost/perf-baseline/evidence/pr-6027-linux.md new file mode 100644 index 00000000000..5bdbc392745 --- /dev/null +++ b/vm/selfhost/perf-baseline/evidence/pr-6027-linux.md @@ -0,0 +1,50 @@ +# PR 6027: Linux translation performance calibration + +Ratios are ParparVM / JDK 25. Each report contains five interleaved paired rounds on four CPUs, with verified output. These measure translation time and peak RSS, not editor startup or typing latency. Existing archived reports were used; this calibration did not dispatch a benchmark. + +## Measurements + +| Run | Source | CPU | Benchmark | Time median | RAM median | Report SHA-256 | +| --- | --- | --- | --- | ---: | ---: | --- | +| [38848075973](https://github.com/codenameone/CodenameOne/actions/runs/38848075973) | master | Neoverse-N2 | hello | 1.012598446 | 0.920985756 | `0c1e0de75f9f47f250da2235175b3a78098deabce13e4ca58cb8b81a31b8f024` | +| [38861108588](https://github.com/codenameone/CodenameOne/actions/runs/38861108588) | master | Neoverse-N2 | hello | 1.021270247 | 0.940316573 | `05857acb028225ee085d3d01f2c8a427fba57f50100263a7391879f62a7906a3` | +| [38848287858](https://github.com/codenameone/CodenameOne/actions/runs/38848287858) | PR 6026 | Neoverse-N2 | hello | 1.030690121 | 0.931268660 | `115238b0cad2ca71e885a0f515c31b05c7224c2d45fa3153c2778e23567e0c32` | +| [38876165702](https://github.com/codenameone/CodenameOne/actions/runs/38876165702) | PR 6026 | Neoverse-N2 | hello | 1.078412961 | 0.907708115 | `94eb1578d7c7d8e93ba05803f5232b27e9f845ac0777dc0b556422357030cb18` | +| [38878637047](https://github.com/codenameone/CodenameOne/actions/runs/38878637047) | PR 6027 | Neoverse-N2 | hello | 1.017838131 | 0.931618386 | `3c107c051f5d23b30bf149b869a74f86a95ddfac141d9518f0c161804ddb0c56` | +| [38876165702](https://github.com/codenameone/CodenameOne/actions/runs/38876165702) | PR 6026 | Xeon Platinum 8573C | translator | 0.666364164 | 0.444110455 | `b179bad6159e6f3fa257fb4f873b805a77568f9ac70a3cdaaf1bac0a8445a68c` | +| [38878637047](https://github.com/codenameone/CodenameOne/actions/runs/38878637047) | PR 6027 | Xeon Platinum 8573C | translator | 0.663889267 | 0.436177843 | `59f1a23b699adfed43f65c86021da5b5b3f632e7517ada35417494f1c509eb6b` | + +Download `linux-screenshot-raw-arm64` or `linux-screenshot-raw-x64` from each run and read `perf/perf-results.json`. Run 38876165702 uses the completed first-attempt artifacts; its later retry was canceled after the Linux PR merged. + +## Source provenance + +The two master runs checked out `aa31e41d3fe7599163407d5547c99c5923629abe`. The PR runs checked out GitHub merge commits `82903452e17e73d7b1a9769b44759fa37c817ce2` (38848287858), `6982fbc6edd5d9e7fa2280af64bb519682ccd4f4` (38876165702), and `fa0a3baf8a0c2f94d92d326ce430b5367cee698a` (38878637047). Their source tree hashes agree: + +- `vm/ByteCodeTranslator`: `bddec40acc84f649773b256e460da612a4487b21`. +- `vm/JavaAPI`: `af99698584dd0cdb62bc6e98cea807df7c0519bb`. +- `vm/benchmarks`: `670af49d9ceff3cea55c4184390637623264545c`. + +The `hello` application inputs evolve with the framework and port; identical VM trees do not establish an identical application corpus. The ARM64 sample includes two master controls and three editor-branch measurements. The x64 translator benchmark translates the translator itself and does not execute editor UI code. + +## Changes and limits + +- ARM64 `hello` time: **0.931x to 1.021x**, the median of five run medians (range 1.013x–1.078x). Keep the 15% tolerance and 0.868x memory baseline. One of these five reports fails the former timing baseline. +- Xeon 8573C `translator`: **0.505x to 0.665x time**, **0.517x to 0.440x memory**, medians of two independent run medians. The former row came from one run. Both newer reports fail its time threshold, and one fails its memory improvement threshold. Keep the existing 20% time and 15% memory tolerances, plus the 0.05x absolute memory floor. Two reports are limited evidence; no narrower tolerance or intentional runtime performance change is inferred. +- Other CPU models, benchmarks, metrics, and the shared policy are unchanged. This records measured ratios; it does not identify the cause of the older calibration. + +## Validation + +All **182 metric verdicts** across these seven archived reports pass when replayed through `perf-gate.py:verdict` against `perf_baseline.load()` with the PR overlay. The old baseline fails four verdicts: one ARM64 time regression, two x64 time regressions, and one x64 memory improvement. + +Reproduce from the downloaded reports (ARM filenames in table order, then x64 filenames in table order): + +```sh +python3 vm/selfhost/calibrate-perf-baseline.py --pr 6027 --all --only hello --metric time \ + --reason "Five matching-CPU reports; see evidence/pr-6027-linux.md" \ + arm-master-1.json arm-master-2.json arm-pr-1.json arm-pr-2.json arm-pr-3.json +python3 vm/selfhost/calibrate-perf-baseline.py --pr 6027 --all --only translator \ + --reason "Two matching-CPU reports; see evidence/pr-6027-linux.md" \ + x64-pr-1.json x64-pr-2.json +python3 -m unittest discover -s vm/selfhost -p test_perf_gate.py +python3 vm/selfhost/perf_baseline.py check --pr 6027 --base origin/master +``` diff --git a/vm/selfhost/perf-baseline/pr/6027.json b/vm/selfhost/perf-baseline/pr/6027.json new file mode 100644 index 00000000000..85bbbf7d5be --- /dev/null +++ b/vm/selfhost/perf-baseline/pr/6027.json @@ -0,0 +1,39 @@ +{ + "pr": 6027, + "rebaseline": { + "linux-arm64@neoverse-n2": { + "hello": { + "all": { + "from": { + "memory": 0.868, + "time": 0.931 + }, + "memory": 0.868, + "reason": "Recalibrate Linux ARM64 Neoverse-N2 hello translation time from five matching-CPU five-round reports (runs 38848075973, 38861108588, 38848287858, 38876165702, 38878637047), including two independent master controls. Median of run medians is 1.021x versus the older 0.931x baseline; measured medians span 1.013-1.078x. Keep the 15% time tolerance and 0.868x memory baseline. This is a baseline calibration, not an editor startup measurement. Evidence and artifact hashes: evidence/pr-6027-linux.md.", + "runs": 5, + "time": 1.021 + } + } + }, + "linux-x64@intel-xeon-platinum-8573c": { + "translator": { + "all": { + "from": { + "memory": 0.517, + "time": 0.505, + "tolerance": { + "time": 0.2 + } + }, + "memory": 0.44, + "reason": "Replace the single-run Linux x64 Xeon Platinum 8573C translator calibration with two matching-CPU five-round reports from independent PRs (38876165702 and 38878637047). Median of run medians: time 0.665x, memory 0.440x; the prior single-run row was 0.505x and 0.517x. Keep the existing 20% time and 15% memory tolerances; no runtime performance change is claimed. Evidence, source provenance and artifact hashes: evidence/pr-6027-linux.md.", + "runs": 2, + "time": 0.665, + "tolerance": { + "time": 0.2 + } + } + } + } + } +}