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;
+ }
+}