This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new bf3a7214405c chore: camel-jbang - an emoji such as a family or a flag 
is one character in the AI prompt, filters, captions and diagrams
bf3a7214405c is described below

commit bf3a7214405c6f5a7da5c1d7f911531aae70973b
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 17 11:44:46 2026 +0200

    chore: camel-jbang - an emoji such as a family or a flag is one character 
in the AI prompt, filters, captions and diagrams
    
    TamboUI 0.6.0 moves its text fields and the editor by grapheme cluster:
    all the code points of an emoji such as a family (three emoji joined by
    ZWJs), a flag or a skin tone at once. The places that handle text on
    their own still went a code point at a time, so Backspace took a family
    apart in five steps, and a cut could leave part of one on the screen.
    
    They now go by the character as it is seen (TuiHelper.characterEnd,
    characterStart, characterOffset and characterWidth):
    - the AI prompt: Backspace, Delete, Left and Right; Up and Down keep the
      column on the screen, as the source editor does; the cursor marks the
      whole emoji under it; a long line scrolls by columns; the Ctrl+R
      preview is cut between emoji and leaves room for the two spaces in
      front of it, so its ellipsis is no longer cut off
    - the list filters, the inline caption and the caret of text fields
    - Left and Right in the completion popup of the source editor, in step
      with the editor
    - captions are typed an emoji at a time, and the drawing tools put an
      emoji in one cell
    - the link markers and labels of the route diagram, the log and history
      views scrolled sideways, and the hanging wrap of the detail views
    
    A line break or an escape stays a character of its own, also after a
    ZWJ, which TamboUI otherwise joins to whatever follows it.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/tui/AiInputLine.java   |  63 ++++-----
 .../jbang/core/commands/tui/AutocompletePopup.java |   8 +-
 .../jbang/core/commands/tui/CaptionOverlay.java    |  12 +-
 .../dsl/jbang/core/commands/tui/DrawOverlay.java   |  13 +-
 .../dsl/jbang/core/commands/tui/FormHelper.java    |   3 +-
 .../dsl/jbang/core/commands/tui/FuzzyFilter.java   |   4 +-
 .../dsl/jbang/core/commands/tui/TuiHelper.java     |  81 ++++++++++--
 .../core/commands/tui/diagram/DiagramText.java     |  15 ++-
 .../commands/tui/diagram/RouteDiagramWidget.java   |  13 +-
 .../jbang/core/commands/tui/AiInputLineTest.java   | 146 +++++++++++++++++++++
 .../core/commands/tui/AutocompletePopupTest.java   |  24 ++++
 .../core/commands/tui/CaptionOverlayTest.java      |  32 +++++
 .../jbang/core/commands/tui/DrawOverlayTest.java   |  12 ++
 .../jbang/core/commands/tui/FormHelperTest.java    |  14 ++
 .../jbang/core/commands/tui/FuzzyFilterTest.java   |  10 ++
 .../dsl/jbang/core/commands/tui/TuiHelperTest.java |  66 ++++++++++
 .../core/commands/tui/diagram/DiagramTextTest.java |  23 ++++
 .../commands/tui/diagram/RouteLinkMarkerTest.java  |  82 ++++++++++++
 18 files changed, 539 insertions(+), 82 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiInputLine.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiInputLine.java
index 1b2d711c0f97..a72957f03a30 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiInputLine.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiInputLine.java
@@ -25,6 +25,7 @@ import dev.tamboui.layout.Alignment;
 import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Style;
 import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
 import dev.tamboui.text.Line;
 import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
@@ -34,8 +35,8 @@ import dev.tamboui.widgets.paragraph.Paragraph;
 
 /**
  * The input line of the AI panel: the question being typed (it may have 
several lines; the cursor moves and deletes a
- * code point at a time, so an emoji is not cut in half), the earlier 
questions (Up and Down, and Ctrl+R to search them
- * backward), and TAB completion of slash commands and their arguments. The 
panel says what completes
+ * character at a time, all the code points of an emoji at once), the earlier 
questions (Up and Down, and Ctrl+R to
+ * search them backward), and TAB completion of slash commands and their 
arguments. The panel says what completes
  * ({@link Completion}); this class does the cycling.
  */
 final class AiInputLine {
@@ -152,13 +153,13 @@ final class AiInputLine {
 
     /** Backspace, Delete, Left, Right, Home, End, and typed text; returns 
whether the key was one of them. */
     boolean handleEditKey(KeyEvent ke) {
-        // the cursor moves and deletes a code point at a time, so an emoji 
(two chars) is not cut in half
+        // the cursor moves and deletes a character at a time: all the code 
points of an emoji, such as a family or a flag
         if (ke.isKey(KeyCode.BACKSPACE)) {
             if (cursorPos > 0) {
                 if (promptHistory != null) {
                     promptHistory.resetNavigation();
                 }
-                int previous = inputBuffer.offsetByCodePoints(cursorPos, -1);
+                int previous = TuiHelper.characterStart(inputBuffer, 
cursorPos);
                 inputBuffer.delete(previous, cursorPos);
                 cursorPos = previous;
             }
@@ -169,19 +170,19 @@ final class AiInputLine {
                 if (promptHistory != null) {
                     promptHistory.resetNavigation();
                 }
-                inputBuffer.delete(cursorPos, 
inputBuffer.offsetByCodePoints(cursorPos, 1));
+                inputBuffer.delete(cursorPos, 
TuiHelper.characterEnd(inputBuffer, cursorPos));
             }
             return true;
         }
         if (ke.isKey(KeyCode.LEFT)) {
             if (cursorPos > 0) {
-                cursorPos = inputBuffer.offsetByCodePoints(cursorPos, -1);
+                cursorPos = TuiHelper.characterStart(inputBuffer, cursorPos);
             }
             return true;
         }
         if (ke.isKey(KeyCode.RIGHT)) {
             if (cursorPos < inputBuffer.length()) {
-                cursorPos = inputBuffer.offsetByCodePoints(cursorPos, 1);
+                cursorPos = TuiHelper.characterEnd(inputBuffer, cursorPos);
             }
             return true;
         }
@@ -258,7 +259,7 @@ final class AiInputLine {
             if (searchTerm.isEmpty()) {
                 exitHistorySearch(false);
             } else {
-                
searchTerm.setLength(searchTerm.offsetByCodePoints(searchTerm.length(), -1));
+                searchTerm.setLength(TuiHelper.characterStart(searchTerm, 
searchTerm.length()));
                 performSearch(promptHistory.size() - 1);
             }
             return true;
@@ -298,8 +299,8 @@ final class AiInputLine {
     /** Moves the cursor to the previous (-1) or next (1) line of the input; 
false when there is no such line. */
     boolean moveCursorLine(int direction) {
         int start = lineStart(cursorPos);
-        // the column in code points, so the cursor does not land inside an 
emoji on the other line
-        int column = inputBuffer.codePointCount(start, cursorPos);
+        // the column on the screen, as in the source editor: the cursor goes 
straight up or down, never into an emoji
+        int column = CharWidth.of(inputBuffer.substring(start, cursorPos));
         int targetStart;
         if (direction < 0) {
             if (start == 0) {
@@ -314,8 +315,7 @@ final class AiInputLine {
             targetStart = end + 1;
         }
         int targetEnd = lineEnd(targetStart);
-        cursorPos = inputBuffer.offsetByCodePoints(targetStart,
-                Math.min(column, inputBuffer.codePointCount(targetStart, 
targetEnd)));
+        cursorPos = targetStart + 
CharWidth.substringByWidth(inputBuffer.substring(targetStart, targetEnd), 
column).length();
         return true;
     }
 
@@ -461,28 +461,22 @@ final class AiInputLine {
             if (locked) {
                 spans.add(Span.styled(text, Style.EMPTY.dim()));
             } else if (i != cursorLine) {
-                spans.add(Span.raw(text.length() > maxWidth ? 
text.substring(0, maxWidth) : text));
+                spans.add(Span.raw(CharWidth.substringByWidth(text, 
maxWidth)));
             } else {
-                // ensure the cursor is visible by adjusting the text window 
of its line
-                int windowStart = cursorColumn > maxWidth - 1 ? cursorColumn - 
maxWidth + 1 : 0;
-                int windowEnd = Math.min(text.length(), windowStart + 
maxWidth);
-                // the window does not cut an emoji (two chars) in half
-                if (windowStart > 0 && windowStart < text.length() && 
Character.isLowSurrogate(text.charAt(windowStart))) {
-                    windowStart++;
-                }
-                if (windowEnd < text.length() && windowEnd > windowStart
-                        && Character.isHighSurrogate(text.charAt(windowEnd - 
1))) {
-                    windowEnd--;
-                }
-                String visible = text.substring(windowStart, 
Math.max(windowStart, windowEnd));
+                // the window of the line keeps the cursor and the character 
under it in view, measured in columns, and
+                // cuts no character in half
+                int cursorEnd = cursorColumn < text.length() ? 
TuiHelper.characterEnd(text, cursorColumn) : cursorColumn;
+                int cursorWidth = Math.max(1, 
CharWidth.of(text.substring(cursorColumn, cursorEnd)));
+                String lead = 
CharWidth.substringByWidthFromEnd(text.substring(0, cursorColumn), maxWidth - 
cursorWidth);
+                int windowStart = cursorColumn - lead.length();
+                String visible = 
CharWidth.substringByWidth(text.substring(windowStart), maxWidth);
                 int cursorInWindow = cursorColumn - windowStart;
-                if (cursorInWindow >= 0 && cursorInWindow < visible.length()) {
-                    // the whole character under the cursor, both chars of an 
emoji
-                    int cursorEnd = Math.min(visible.length(),
-                            cursorInWindow + 
Character.charCount(visible.codePointAt(cursorInWindow)));
+                if (cursorEnd > cursorColumn && cursorEnd - windowStart <= 
visible.length()) {
+                    // the whole character under the cursor, all the code 
points of an emoji
                     spans.add(Span.raw(visible.substring(0, cursorInWindow)));
-                    spans.add(Span.styled(visible.substring(cursorInWindow, 
cursorEnd), Style.EMPTY.reversed()));
-                    spans.add(Span.raw(visible.substring(cursorEnd)));
+                    spans.add(Span.styled(visible.substring(cursorInWindow, 
cursorEnd - windowStart),
+                            Style.EMPTY.reversed()));
+                    spans.add(Span.raw(visible.substring(cursorEnd - 
windowStart)));
                 } else {
                     spans.add(Span.raw(visible));
                     if (cursorInWindow == visible.length()) {
@@ -516,11 +510,10 @@ final class AiInputLine {
 
         if (searchIndex >= 0) {
             String matched = promptHistory.get(searchIndex);
-            int maxWidth = area.width() - label.length() - query.length() - 1;
+            // the room after the label, the query, its caret and the two 
spaces before the preview
+            int maxWidth = area.width() - label.length() - CharWidth.of(query) 
- 3;
             if (maxWidth > 4) {
-                String preview = matched.length() > maxWidth
-                        ? matched.substring(0, maxWidth - 1) + "…"
-                        : matched;
+                String preview = CharWidth.truncateWithEllipsis(matched, 
maxWidth, "…", CharWidth.TruncatePosition.END);
                 spans.add(Span.styled("  " + preview, Style.EMPTY.dim()));
             }
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
index aff2ec41c625..68afc2e624b2 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
@@ -152,10 +152,12 @@ class AutocompletePopup {
             listState.selectNext(size);
             return Result.CONSUMED;
         }
+        // a character at a time, as the editor moves: all the code points of 
an emoji
         if (ke.isRight()) {
             if (cursorPos < lineKeyText.length()) {
-                filter.appendChar(lineKeyText.charAt(cursorPos));
-                cursorPos++;
+                int end = TuiHelper.characterEnd(lineKeyText, cursorPos);
+                filter.append(lineKeyText.substring(cursorPos, end));
+                cursorPos = end;
                 rebuildList();
                 return Result.CURSOR_RIGHT;
             }
@@ -164,7 +166,7 @@ class AutocompletePopup {
         if (ke.isLeft()) {
             if (cursorPos > minCursorPos && filter.hasFilter()) {
                 filter.deleteChar();
-                cursorPos--;
+                cursorPos = TuiHelper.characterStart(lineKeyText, cursorPos);
                 rebuildList();
                 return Result.CURSOR_LEFT;
             }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
index a1e1a29f42c2..d04b0e2e3fe5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
@@ -119,8 +119,8 @@ class CaptionOverlay {
                 finishInline();
             } else if (ke.isDeleteBackward()) {
                 if (!inlineBuffer.isEmpty()) {
-                    // the last character, both chars of an emoji
-                    
inlineBuffer.setLength(inlineBuffer.offsetByCodePoints(inlineBuffer.length(), 
-1));
+                    // the last character, all the code points of an emoji
+                    
inlineBuffer.setLength(TuiHelper.characterStart(inlineBuffer, 
inlineBuffer.length()));
                     captionText = inlineBuffer.toString();
                     inlineLastKeystroke = clock.getAsLong();
                 }
@@ -160,11 +160,10 @@ class CaptionOverlay {
             return;
         }
 
-        int totalChars = captionText.codePointCount(0, captionText.length());
         long elapsed = now - captionStartTime;
         int charsToShow = (int) (elapsed / CHAR_DELAY_MS);
 
-        if (charsToShow >= totalChars && captionFullyTypedTime == 0) {
+        if (captionFullyTypedTime == 0 && 
TuiHelper.characterOffset(captionText, charsToShow) == captionText.length()) {
             captionFullyTypedTime = now;
         }
         if (captionAutoDismissTime == 0
@@ -236,9 +235,8 @@ class CaptionOverlay {
     private void renderCaption(Frame frame, Rect area) {
         long now = clock.getAsLong();
         long elapsed = now - captionStartTime;
-        // typed a character at a time, an emoji (two chars) at once
-        int charsToShow = Math.min((int) (elapsed / CHAR_DELAY_MS), 
captionText.codePointCount(0, captionText.length()));
-        String visible = captionText.substring(0, 
captionText.offsetByCodePoints(0, charsToShow));
+        // typed a character at a time, all the code points of an emoji at once
+        String visible = captionText.substring(0, 
TuiHelper.characterOffset(captionText, (int) (elapsed / CHAR_DELAY_MS)));
 
         Style style;
         if (captionFullyTypedTime == 0 || now - captionFullyTypedTime < 
HOLD_DURATION_MS) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
index 25508321717e..3650e0853aff 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
@@ -28,7 +28,6 @@ import dev.tamboui.style.Color;
 import dev.tamboui.style.ColorConverter;
 import dev.tamboui.style.Style;
 import dev.tamboui.terminal.Frame;
-import dev.tamboui.text.CharWidth;
 
 class DrawOverlay {
 
@@ -174,17 +173,17 @@ class DrawOverlay {
         int col = x;
         int row = y;
         for (int i = 0; i < text.length();) {
-            int cp = text.codePointAt(i);
-            if (cp == '\n') {
+            if (text.charAt(i) == '\n') {
                 row++;
                 col = x;
                 i++;
                 continue;
             }
-            String ch = new String(Character.toChars(cp));
-            cells.add(new DrawCell(col, row, ch, s));
-            col += Math.max(1, CharWidth.of(cp));
-            i += Character.charCount(cp);
+            // a cell for each character, all the code points of an emoji in 
one
+            int end = TuiHelper.characterEnd(text, i);
+            cells.add(new DrawCell(col, row, text.substring(i, end), s));
+            col += Math.max(1, TuiHelper.characterWidth(text, i, end));
+            i = end;
         }
         return cells;
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java
index c12887bce726..b02ef9155730 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java
@@ -111,7 +111,8 @@ final class FormHelper {
             spans.add(Span.raw(text + "█"));
             return;
         }
-        int end = pos + Character.charCount(text.codePointAt(pos));
+        // the whole character, all the code points of an emoji
+        int end = TuiHelper.characterEnd(text, pos);
         spans.add(Span.raw(text.substring(0, pos)));
         spans.add(Span.styled(text.substring(pos, end), 
Style.EMPTY.reversed()));
         spans.add(Span.raw(text.substring(end)));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
index c0f5dc7e431d..067e777acf8e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilter.java
@@ -54,10 +54,10 @@ class FuzzyFilter {
         filter += typed.toLowerCase(Locale.ROOT);
     }
 
-    /** Removes the last character typed, both chars of one outside the basic 
plane. */
+    /** Removes the last character typed, all the code points of an emoji. */
     void deleteChar() {
         if (!filter.isEmpty()) {
-            filter = filter.substring(0, 
filter.offsetByCodePoints(filter.length(), -1));
+            filter = filter.substring(0, TuiHelper.characterStart(filter, 
filter.length()));
         }
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
index 8a9797ae5c87..a28c3ec63113 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
@@ -51,6 +51,9 @@ import org.apache.camel.util.json.Jsoner;
  */
 final class TuiHelper {
 
+    // the zero width joiner, which joins the emoji of a sequence such as a 
family
+    private static final int ZWJ = 0x200D;
+
     private TuiHelper() {
     }
 
@@ -215,6 +218,59 @@ final class TuiHelper {
                 : s;
     }
 
+    /**
+     * The end of the character at {@code index}, the way it is seen: all the 
code points of an emoji such as a ZWJ
+     * sequence (a family), a flag or a skin tone (a grapheme cluster). A 
control character, such as a line break or an
+     * escape, is always a character of its own: TamboUI joins whatever 
follows a ZWJ, so a ZWJ next to one is taken as
+     * a ZWJ at the end of the text.
+     */
+    static int characterEnd(CharSequence text, int index) {
+        if (Character.isISOControl(text.charAt(index))) {
+            return index + 1;
+        }
+        int end = GraphemeClusters.clusterEnd(text, index);
+        for (int i = index + 1; i < end; i++) {
+            if (Character.isISOControl(text.charAt(i))) {
+                // only the code point after a ZWJ can be one: the character 
ends before that ZWJ
+                return i - 1;
+            }
+        }
+        return end;
+    }
+
+    /** The start of the character that ends at {@code index}; see {@link 
#characterEnd(CharSequence, int)}. */
+    static int characterStart(CharSequence text, int index) {
+        if (Character.isISOControl(text.charAt(index - 1))) {
+            return index - 1;
+        }
+        int start = GraphemeClusters.clusterStart(text, index);
+        for (int i = index - 2; i >= start; i--) {
+            if (Character.isISOControl(text.charAt(i))) {
+                // the base of combining marks, or before a ZWJ, which then is 
a character of its own as well
+                return text.charAt(i + 1) == ZWJ && i + 2 < index ? i + 2 : i 
+ 1;
+            }
+        }
+        return start;
+    }
+
+    /** The index after the first {@code count} characters of the text, its 
length when it has fewer. */
+    static int characterOffset(CharSequence text, int count) {
+        int end = 0;
+        for (int i = 0; i < count && end < text.length(); i++) {
+            end = characterEnd(text, end);
+        }
+        return end;
+    }
+
+    /**
+     * The columns of the character from {@code start} to {@code end}: an 
emoji of several code points takes those of
+     * one, where adding up its code points would count a family as six.
+     */
+    static int characterWidth(String text, int start, int end) {
+        int cp = text.codePointAt(start);
+        return start + Character.charCount(cp) == end ? CharWidth.of(cp) : 
CharWidth.of(text.substring(start, end));
+    }
+
     /**
      * Convert an Object (typically from JSON) to a long value.
      */
@@ -375,18 +431,18 @@ final class TuiHelper {
             } else if (ch == '\r') {
                 i++;
             } else {
-                int cp = raw.codePointAt(i);
-                int w = CharWidth.of(cp);
-                int charLen = Character.charCount(cp);
+                // a character at a time, all the code points of an emoji as 
one
+                int end = characterEnd(raw, i);
+                int w = characterWidth(raw, i, end);
                 if (consumed >= hSkip) {
-                    text.append(raw, i, i + charLen);
+                    text.append(raw, i, end);
                 } else if (consumed + w > hSkip) {
                     // Wide char straddles the skip boundary — skip it entirely
                 }
                 if (w > 0) {
                     consumed += w;
                 }
-                i += charLen;
+                i = end;
             }
         }
         if (text.length() > 0) {
@@ -421,9 +477,9 @@ final class TuiHelper {
                 int i = 0;
                 int consumed = 0;
                 while (i < text.length() && consumed < skip) {
-                    int cp = text.codePointAt(i);
-                    consumed += CharWidth.of(cp);
-                    i += Character.charCount(cp);
+                    int end = characterEnd(text, i);
+                    consumed += characterWidth(text, i, end);
+                    i = end;
                 }
                 skip = 0;
                 String remaining = text.substring(i);
@@ -767,12 +823,13 @@ final class TuiHelper {
         StringBuilder plain = new StringBuilder();
         for (Span span : line.spans()) {
             String content = span.content();
+            // a character at a time, so a wrap never splits an emoji of 
several code points
             for (int i = 0; i < content.length();) {
-                int cp = content.codePointAt(i);
-                String ch = new String(Character.toChars(cp));
-                chars.add(new StyledChar(ch, span.style(), Math.max(0, 
CharWidth.of(cp))));
+                int end = characterEnd(content, i);
+                String ch = content.substring(i, end);
+                chars.add(new StyledChar(ch, span.style(), Math.max(0, 
characterWidth(content, i, end))));
                 plain.append(ch);
-                i += Character.charCount(cp);
+                i = end;
             }
         }
         String text = plain.toString();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramText.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramText.java
index 6bb413deb1ec..2d195ae73c64 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramText.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramText.java
@@ -23,6 +23,7 @@ import dev.tamboui.buffer.Buffer;
 import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Style;
 import dev.tamboui.text.CharWidth;
+import dev.tamboui.text.GraphemeClusters;
 
 /**
  * Text in the diagrams, measured by display width as the terminal shows it: a 
CJK character or an emoji takes two
@@ -62,8 +63,8 @@ final class DiagramText {
                 }
             }
             if (breakAt <= 0) {
-                // no place to break within the width: the word is cut, by one 
character at least
-                breakAt = Math.max(fit, 
Character.charCount(remaining.codePointAt(0)));
+                // no place to break within the width: the word is cut, by one 
character at least (an emoji whole)
+                breakAt = Math.max(fit, GraphemeClusters.clusterEnd(remaining, 
0));
             }
             lastLineText = remaining;
             lines.add(remaining.substring(0, breakAt).stripTrailing());
@@ -86,13 +87,13 @@ final class DiagramText {
         }
         String visible = text;
         if (x < area.left()) {
-            // the characters left of the area are skipped; a wide one cut by 
the edge goes too and leaves its column in
-            // the area blank, so the rest of the text keeps its columns
+            // the characters left of the area are skipped, an emoji of 
several code points as one; a wide one cut by
+            // the edge goes too and leaves its column in the area blank, so 
the rest of the text keeps its columns
             int i = 0;
             while (i < text.length() && x < area.left()) {
-                int cp = text.codePointAt(i);
-                x += CharWidth.of(cp);
-                i += Character.charCount(cp);
+                int end = GraphemeClusters.clusterEnd(text, i);
+                x += CharWidth.of(text.substring(i, end));
+                i = end;
             }
             if (x >= area.right()) {
                 return;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
index 8574a4cd1215..ab4efc2867b5 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
@@ -27,6 +27,7 @@ import dev.tamboui.layout.Rect;
 import dev.tamboui.style.Color;
 import dev.tamboui.style.Style;
 import dev.tamboui.text.CharWidth;
+import dev.tamboui.text.GraphemeClusters;
 import dev.tamboui.widget.Widget;
 import org.apache.camel.diagram.RouteDiagramLayoutEngine;
 import org.apache.camel.diagram.RouteDiagramLayoutEngine.Bounds;
@@ -228,17 +229,13 @@ public class RouteDiagramWidget implements Widget {
         if (y < area.top() || y >= area.bottom()) {
             return;
         }
-        // a character at a time, by the columns it takes: an emoji or a CJK 
character two
+        // a character at a time, by the columns it takes: an emoji or a CJK 
character two, and all the code points of
+        // an emoji (a ZWJ sequence, a flag) or of a letter with a combining 
mark go together
         String text = m.text();
         int offset = 0;
         for (int i = 0; i < text.length();) {
-            int cp = text.codePointAt(i);
-            int end = i + Character.charCount(cp);
-            // a combining mark goes with the character before it
-            while (end < text.length() && CharWidth.of(text.codePointAt(end)) 
== 0) {
-                end += Character.charCount(text.codePointAt(end));
-            }
-            int width = Math.max(1, CharWidth.of(cp));
+            int end = GraphemeClusters.clusterEnd(text, i);
+            int width = Math.max(1, CharWidth.of(text.substring(i, end)));
             int x = area.x() + m.col() - scrollX + offset;
             if (x + width > area.right()) {
                 return;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiInputLineTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiInputLineTest.java
new file mode 100644
index 000000000000..f4e27afd45c6
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiInputLineTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Modifier;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The AI prompt moves over and deletes an emoji of several code points (a 
family, a flag) as one character, and draws
+ * it whole.
+ */
+class AiInputLineTest {
+
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
+
+    @TempDir
+    Path dir;
+
+    @Test
+    void anEmojiOfSeveralCodePointsIsMovedOverAndDeletedAsOneCharacter() {
+        AiInputLine input = new AiInputLine();
+        input.insert("a" + FAMILY + "b");
+
+        // Left over the b and the whole family: x goes in front of it
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.LEFT));
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.LEFT));
+        input.handleEditKey(KeyEvent.ofChar('x'));
+        assertThat(input.text()).isEqualTo("ax" + FAMILY + "b");
+
+        // Delete takes the whole family
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.DELETE));
+        assertThat(input.text()).isEqualTo("axb");
+
+        // Backspace takes a flag, then a family, each whole
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.END));
+        input.insert(FAMILY + "🇫🇷");
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.BACKSPACE));
+        assertThat(input.text()).isEqualTo("axb" + FAMILY);
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.BACKSPACE));
+        assertThat(input.text()).isEqualTo("axb");
+    }
+
+    @Test
+    void upAndDownKeepTheColumnOnTheScreenAndNeverLandInsideAnEmoji() {
+        AiInputLine input = new AiInputLine();
+        input.insert("abcdef\n" + FAMILY + FAMILY + "x");
+
+        // after the two families, four columns in: Up goes to after the d
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.HOME));
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.RIGHT));
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.RIGHT));
+        assertThat(input.moveCursorLine(-1)).isTrue();
+        input.handleEditKey(KeyEvent.ofChar('|'));
+        assertThat(input.text()).isEqualTo("abcd|ef\n" + FAMILY + FAMILY + 
"x");
+
+        // three columns in: Down goes to after the first family, not into the 
second
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.BACKSPACE));
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.LEFT));
+        assertThat(input.moveCursorLine(1)).isTrue();
+        input.handleEditKey(KeyEvent.ofChar('|'));
+        assertThat(input.text()).isEqualTo("abcdef\n" + FAMILY + "|" + FAMILY 
+ "x");
+    }
+
+    @Test
+    void theCursorMarksTheWholeEmojiUnderIt() {
+        AiInputLine input = new AiInputLine();
+        input.insert("a" + FAMILY + "b");
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.LEFT));
+        input.handleEditKey(KeyEvent.ofKey(KeyCode.LEFT));
+
+        Buffer buffer = render(input, 20);
+
+        // after the "> " prompt and the a: the family in one reversed cell, 
then the b
+        assertThat(buffer.get(3, 0).symbol()).isEqualTo(FAMILY);
+        assertThat(buffer.get(3, 
0).style().effectiveModifiers()).contains(Modifier.REVERSED);
+        assertThat(buffer.get(5, 0).symbol()).isEqualTo("b");
+        assertThat(buffer.get(5, 
0).style().effectiveModifiers()).doesNotContain(Modifier.REVERSED);
+    }
+
+    @Test
+    void aLongLineScrollsByColumnsAndCutsNoEmoji() {
+        AiInputLine input = new AiInputLine();
+        input.insert("a" + FAMILY.repeat(20));
+
+        // the cursor at the end: the nine families that fit before it in 19 
columns, each whole
+        Buffer buffer = render(input, 21);
+
+        for (int x = 2; x <= 18; x += 2) {
+            assertThat(buffer.get(x, 0).symbol()).isEqualTo(FAMILY);
+        }
+        assertThat(buffer.get(20, 
0).style().effectiveModifiers()).contains(Modifier.REVERSED);
+    }
+
+    @Test
+    void theSearchPreviewIsCutBetweenEmoji() {
+        AiInputLine input = new AiInputLine();
+        input.setPromptHistory(TuiPromptHistory.load(10, 
dir.resolve("history")));
+        input.remember("x" + FAMILY.repeat(10));
+        input.enterSearch();
+        input.handleSearchKey(KeyEvent.ofChar('x'));
+
+        Buffer buffer = render(input, 30);
+
+        // the families that fit, each whole, and the ellipsis in the last 
column
+        for (int x = 0; x < 30; x++) {
+            String symbol = buffer.get(x, 0).symbol();
+            if (symbol.contains("👨") || symbol.contains("👩") || 
symbol.contains("👧")) {
+                assertThat(symbol).isEqualTo(FAMILY);
+            }
+        }
+        assertThat(buffer.get(29, 0).symbol()).isEqualTo("…");
+    }
+
+    private static Buffer render(AiInputLine input, int width) {
+        Rect area = new Rect(0, 0, width, 1);
+        Buffer buffer = Buffer.empty(area);
+        input.render(Frame.forTesting(buffer), area, "> ", false, text -> 
Optional.empty());
+        return buffer;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
index 32b1d91ae4aa..c94754ae9b2a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
@@ -27,6 +27,9 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 class AutocompletePopupTest {
 
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
+
     @Test
     void theAdvancedOptionsComeAfterTheCommonOnes() {
         var items = List.of(
@@ -205,4 +208,25 @@ class AutocompletePopupTest {
                 new AutocompletePopup.CompletionItem("beta", "Second item", 
"integer", 42, false, null, "common"),
                 new AutocompletePopup.CompletionItem("gamma", "Third item", 
"boolean", true, true, "use delta", "advanced"));
     }
+
+    @Test
+    void rightAndLeftGoOverAWholeEmojiAsTheEditorDoes() {
+        var popup = new AutocompletePopup(sampleItems(), "", FAMILY + "a");
+
+        // the family is one step, the a another, and then the text ends
+        assertThat(popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, 
KeyModifiers.NONE)))
+                .isEqualTo(AutocompletePopup.Result.CURSOR_RIGHT);
+        assertThat(popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, 
KeyModifiers.NONE)))
+                .isEqualTo(AutocompletePopup.Result.CURSOR_RIGHT);
+        assertThat(popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, 
KeyModifiers.NONE)))
+                .isEqualTo(AutocompletePopup.Result.CONSUMED);
+
+        // and back the same steps
+        assertThat(popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, 
KeyModifiers.NONE)))
+                .isEqualTo(AutocompletePopup.Result.CURSOR_LEFT);
+        assertThat(popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, 
KeyModifiers.NONE)))
+                .isEqualTo(AutocompletePopup.Result.CURSOR_LEFT);
+        assertThat(popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, 
KeyModifiers.NONE)))
+                .isEqualTo(AutocompletePopup.Result.CONSUMED);
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlayTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlayTest.java
index 7183dc1e5b14..c1e0361bd5d0 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlayTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlayTest.java
@@ -17,6 +17,7 @@
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
 
 import dev.tamboui.buffer.Buffer;
 import dev.tamboui.layout.Rect;
@@ -29,6 +30,9 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 class CaptionOverlayTest {
 
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
+
     @Test
     void anInlineCaptionInWideCharactersIsNotCut() {
         CaptionOverlay caption = new CaptionOverlay();
@@ -56,4 +60,32 @@ class CaptionOverlayTest {
 
         assertThat(caption.captionTextForTesting()).isEqualTo("a");
     }
+
+    @Test
+    void backspaceInAnInlineCaptionDeletesAWholeEmojiOfSeveralCodePoints() {
+        CaptionOverlay caption = new CaptionOverlay();
+        caption.openInline();
+        caption.handleKeyEvent(KeyEvent.ofChar('a'));
+        // a family arrives as five keys, a code point each
+        FAMILY.codePoints().forEach(cp -> 
caption.handleKeyEvent(KeyEvent.ofChar(cp)));
+        caption.handleKeyEvent(KeyEvent.ofKey(KeyCode.BACKSPACE));
+        caption.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+
+        assertThat(caption.captionTextForTesting()).isEqualTo("a");
+    }
+
+    @Test
+    void aCaptionIsTypedAWholeEmojiAtATime() {
+        AtomicLong now = new AtomicLong(1_000);
+        CaptionOverlay caption = new CaptionOverlay(now::get);
+        caption.showCaption("a" + FAMILY + "b", 0);
+        Rect area = new Rect(0, 0, 40, 5);
+
+        // a character every 50 ms: after 100 ms the a and the whole family, 
not the first emoji of it
+        now.addAndGet(100);
+        Buffer buffer = Buffer.empty(area);
+        caption.render(Frame.forTesting(buffer), area);
+
+        assertThat(TuiTestHelper.bufferToString(buffer)).contains("a" + 
FAMILY).doesNotContain("b");
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlayTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlayTest.java
index fd4a45cf5a8d..376b5e3eaf42 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlayTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlayTest.java
@@ -23,6 +23,9 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 class DrawOverlayTest {
 
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
+
     @Test
     void parseColorTakesTheNamesTheToolsDocument() {
         assertThat(DrawOverlay.parseColor("red")).isEqualTo(Color.RED);
@@ -56,4 +59,13 @@ class DrawOverlayTest {
         assertThat(DrawOverlay.parseColor("chartreuse-ish")).isNull();
         assertThat(DrawOverlay.parseColor("#12")).isNull();
     }
+
+    @Test
+    void textPutsAWholeEmojiInOneCell() {
+        var cells = DrawOverlay.generateText(0, 0, "a" + FAMILY + "b", 
Color.RED);
+
+        // the family in one cell, two columns wide
+        
assertThat(cells).extracting(DrawOverlay.DrawCell::symbol).containsExactly("a", 
FAMILY, "b");
+        
assertThat(cells).extracting(DrawOverlay.DrawCell::x).containsExactly(0, 1, 3);
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelperTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelperTest.java
index a88ecb857bf9..6ee4d98ee631 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelperTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelperTest.java
@@ -33,6 +33,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 class FormHelperTest {
 
     private static final String SMILE = "😀";
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
     private static final KeyModifiers CTRL = KeyModifiers.of(true, false, 
false);
     private static final KeyModifiers ALT = KeyModifiers.of(false, true, 
false);
 
@@ -94,4 +96,16 @@ class FormHelperTest {
         assertThat(inside).extracting(Span::content).containsExactly("cam", 
"e", "l");
         
assertThat(inside.get(1).style().effectiveModifiers()).contains(Modifier.REVERSED);
     }
+
+    @Test
+    void caretFieldMarksTheWholeEmojiAtTheCursor() {
+        TextInputState state = new TextInputState("a" + FAMILY + "b");
+        state.moveCursorToEnd();
+        state.moveCursorLeft();
+        state.moveCursorLeft();
+
+        List<Span> spans = new ArrayList<>();
+        FormHelper.addCaretField(spans, state);
+        assertThat(spans).extracting(Span::content).containsExactly("a", 
FAMILY, "b");
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
index 6cf2b6ab64dd..85acd8aa08b9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FuzzyFilterTest.java
@@ -242,4 +242,14 @@ class FuzzyFilterTest {
         filter.deleteChar();
         assertEquals("a", filter.filter());
     }
+
+    @Test
+    void deleteTakesAWholeEmojiOfSeveralCodePoints() {
+        FuzzyFilter filter = new FuzzyFilter();
+        // a family: three emoji joined by ZWJs
+        filter.append("a👨\u200D👩\u200D👧");
+
+        filter.deleteChar();
+        assertEquals("a", filter.filter());
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
index dd5a1c9b265f..21d9641fc859 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelperTest.java
@@ -417,4 +417,70 @@ class TuiHelperTest {
         }
         return sb.toString();
     }
+
+    // ---- characters: an emoji of several code points is one ----
+
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
+
+    @Test
+    void characterTakesAllTheCodePointsOfAnEmoji() {
+        // the family, a flag (two regional indicators), a skin tone, and an e 
with a combining accent
+        String text = "a" + FAMILY + "🇫🇷👍🏽e\u0301";
+        int flag = 1 + FAMILY.length();
+        assertEquals(flag, TuiHelper.characterEnd(text, 1));
+        assertEquals(1, TuiHelper.characterStart(text, flag));
+        assertEquals(flag + 4, TuiHelper.characterEnd(text, flag));
+        assertEquals(flag + 8, TuiHelper.characterEnd(text, flag + 4));
+        assertEquals(text.length(), TuiHelper.characterEnd(text, flag + 8));
+        assertEquals(flag + 8, TuiHelper.characterStart(text, text.length()));
+        // counted in characters: the a, the family and the flag
+        assertEquals(flag + 4, TuiHelper.characterOffset(text, 3));
+        assertEquals(text.length(), TuiHelper.characterOffset(text, 99));
+        assertEquals(2, TuiHelper.characterWidth(text, 1, flag));
+    }
+
+    @Test
+    void aControlCharacterIsACharacterOfItsOwnAlsoNextToAZwj() {
+        // a ZWJ with nothing to join, before a line break or an escape, is 
taken as one at the end of the text
+        String text = "👩\u200D\n\u001B[0m";
+        assertEquals(2, TuiHelper.characterEnd(text, 0));
+        assertEquals(3, TuiHelper.characterEnd(text, 2));
+        assertEquals(4, TuiHelper.characterEnd(text, 3));
+        assertEquals(5, TuiHelper.characterEnd(text, 4));
+        // the same characters going back
+        assertEquals(3, TuiHelper.characterStart(text, 4));
+        assertEquals(2, TuiHelper.characterStart(text, 3));
+        assertEquals(0, TuiHelper.characterStart(text, 2));
+        // and a ZWJ after a line break
+        String after = "\n\u200D👩";
+        assertEquals(2, TuiHelper.characterStart(after, after.length()));
+        assertEquals(1, TuiHelper.characterStart(after, 2));
+        assertEquals(0, TuiHelper.characterStart(after, 1));
+    }
+
+    @Test
+    void ansiToLineSkipsAnEmojiOfSeveralCodePointsByItsColumns() {
+        // the family takes two columns, not the six of its three emoji
+        assertEquals("AB", rawContent(TuiHelper.ansiToLine(FAMILY + "AB", 2)));
+        // a ZWJ before an escape leaves the escape alone: red is red
+        Line line = TuiHelper.ansiToLine("👩\u200D\u001B[31mred", 0);
+        assertEquals("👩\u200Dred", rawContent(line));
+        Span last = line.spans().get(line.spans().size() - 1);
+        assertEquals("red", last.content());
+        assertEquals(Color.ansi(AnsiColor.RED), 
last.style().fg().orElse(null));
+    }
+
+    @Test
+    void skipColumnsSkipsAWholeEmojiOfSeveralCodePoints() {
+        assertEquals("AB", 
rawContent(TuiHelper.skipColumns(Line.from(Span.raw(FAMILY + "AB")), 2)));
+    }
+
+    @Test
+    void hangingWrapKeepsAnEmojiOfSeveralCodePointsWhole() {
+        // four families fill a row of eight columns; counted by their code 
points, a family would take six
+        List<Line> rows = TuiHelper.hangingWrap(List.of(Line.from("key = " + 
FAMILY.repeat(6))), 8);
+        assertEquals(List.of("key = ", FAMILY.repeat(4), FAMILY.repeat(2)),
+                rows.stream().map(TuiHelperTest::rawContent).toList());
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramTextTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramTextTest.java
index aa1333cb2ce0..9cc7fbe93d6f 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramTextTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramTextTest.java
@@ -26,6 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 class DiagramTextTest {
 
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
+
     @Test
     void wrapMeasuresByDisplayWidth() {
         // ten CJK characters are 20 columns
@@ -85,4 +88,24 @@ class DiagramTextTest {
         assertThat(DiagramText.fit("orders", 10)).isEqualTo("orders");
         assertThat(DiagramText.fit("日本語の名前", 7)).isEqualTo("日本語…");
     }
+
+    @Test
+    void wrapCutsAWordByAWholeEmojiAtLeast() {
+        // no line is wide enough for a family: the cut takes all of one, not 
its first emoji
+        assertThat(DiagramText.wrap(FAMILY + FAMILY, 1, 
3)).containsExactly(FAMILY, FAMILY);
+    }
+
+    @Test
+    void writeSkipsAnEmojiOfSeveralCodePointsLeftOfTheAreaByItsColumns() {
+        Buffer buffer = Buffer.empty(new Rect(0, 0, 12, 1));
+
+        // the family takes columns 0 and 1, left of the area
+        DiagramText.write(buffer, new Rect(2, 0, 10, 1), 0, 0, FAMILY + "ab", 
Style.EMPTY);
+
+        assertThat(buffer.get(2, 0).symbol()).isEqualTo("a");
+        assertThat(buffer.get(3, 0).symbol()).isEqualTo("b");
+        // no part of the family is drawn left of the area, where another box 
may be
+        assertThat(buffer.get(0, 0).symbol()).isBlank();
+        assertThat(buffer.get(1, 0).symbol()).isBlank();
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteLinkMarkerTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteLinkMarkerTest.java
new file mode 100644
index 000000000000..6e4e276a0d0a
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteLinkMarkerTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui.diagram;
+
+import java.util.Map;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The ↵ beside a box that calls another route names that route; an emoji of 
several code points in the name is drawn as
+ * one character.
+ */
+class RouteLinkMarkerTest {
+
+    // a family: three emoji joined by ZWJs, eight chars that take two columns
+    private static final String FAMILY = "👨\u200D👩\u200D👧";
+
+    @Test
+    void theMarkerDrawsAnEmojiOfSeveralCodePointsInOneCell() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "orders";
+        route.nodes.add(node("route", null, 0));
+        route.nodes.add(node("from", "timer:tick", 1));
+        route.nodes.add(node("to", "direct:family", 2));
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute layout = engine.layoutRoute(route, 0);
+        RouteDiagramWidget widget = new RouteDiagramWidget(
+                layout, engine.getNodeWidth(), 0, 0, 0, false, 
Map.of("direct:family", FAMILY + "x"));
+        Rect area = new Rect(0, 0, 120, 30);
+        Buffer buffer = Buffer.empty(area);
+        widget.render(area, buffer);
+
+        // the ↵, a space, the family in one cell of two columns, then the x
+        int[] arrow = find(buffer, "↵");
+        assertThat(arrow).isNotNull();
+        assertThat(buffer.get(arrow[0] + 2, 
arrow[1]).symbol()).isEqualTo(FAMILY);
+        assertThat(buffer.get(arrow[0] + 4, arrow[1]).symbol()).isEqualTo("x");
+    }
+
+    private static NodeInfo node(String type, String uri, int level) {
+        NodeInfo n = new NodeInfo();
+        n.type = type;
+        n.id = uri != null ? type + "1" : "orders";
+        n.uri = uri;
+        n.code = uri != null ? type + "[" + uri + "]" : "route[orders]";
+        n.level = level;
+        return n;
+    }
+
+    private static int[] find(Buffer buffer, String symbol) {
+        for (int y = 0; y < buffer.height(); y++) {
+            for (int x = 0; x < buffer.width(); x++) {
+                if (symbol.equals(buffer.get(x, y).symbol())) {
+                    return new int[] { x, y };
+                }
+            }
+        }
+        return null;
+    }
+}

Reply via email to