Skip to content

Commit 1dfc8ff

Browse files
committed
feat: Add mode 2027 (grapheme cluster) support with DECRQM probing
Add Terminal.supportsGraphemeClusterMode() and setGraphemeClusterMode() to enable UAX #29 grapheme cluster segmentation for correct cursor positioning of multi-codepoint characters (ZWJ emoji, flags, etc.). Support is detected at runtime via DECRQM (CSI ? 2027 $ p) rather than guessing from the terminal type string, since many terminals report xterm-256color without supporting mode 2027.
1 parent 54af55f commit 1dfc8ff

5 files changed

Lines changed: 522 additions & 0 deletions

File tree

terminal/src/main/java/org/jline/terminal/Terminal.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,6 +1341,40 @@ enum MouseTracking {
13411341
*/
13421342
boolean trackFocus(boolean tracking);
13431343

1344+
/**
1345+
* Returns whether the terminal supports mode 2027 (grapheme cluster / Unicode Core).
1346+
*
1347+
* <p>
1348+
* Mode 2027 allows the terminal to use UAX #29 grapheme cluster segmentation
1349+
* instead of per-codepoint {@code wcwidth()} for cursor positioning. This matters
1350+
* for multi-codepoint characters like ZWJ emoji sequences (e.g., family emoji),
1351+
* which would otherwise be counted as multiple separate characters.
1352+
* </p>
1353+
*
1354+
* @return {@code true} if the terminal supports mode 2027, {@code false} otherwise
1355+
* @see #setGraphemeClusterMode(boolean)
1356+
*/
1357+
default boolean supportsGraphemeClusterMode() {
1358+
return false;
1359+
}
1360+
1361+
/**
1362+
* Enables or disables mode 2027 (grapheme cluster / Unicode Core).
1363+
*
1364+
* <p>
1365+
* When enabled, the terminal uses UAX #29 grapheme cluster segmentation for
1366+
* cursor positioning. This allows multi-codepoint characters like ZWJ emoji
1367+
* sequences to be treated as single display units.
1368+
* </p>
1369+
*
1370+
* @param enable {@code true} to enable grapheme cluster mode, {@code false} to disable it
1371+
* @return {@code true} if the operation succeeded, {@code false} otherwise
1372+
* @see #supportsGraphemeClusterMode()
1373+
*/
1374+
default boolean setGraphemeClusterMode(boolean enable) {
1375+
return false;
1376+
}
1377+
13441378
/**
13451379
* Returns the color palette for this terminal.
13461380
*

terminal/src/main/java/org/jline/terminal/impl/AbstractTerminal.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public abstract class AbstractTerminal implements TerminalExt {
8383
protected Runnable onClose;
8484
protected MouseTracking currentMouseTracking = MouseTracking.Off;
8585
protected volatile boolean closed = false;
86+
private Boolean graphemeClusterModeSupported;
8687

8788
public AbstractTerminal(String name, String type) throws IOException {
8889
this(name, type, null, SignalHandler.SIG_DFL);
@@ -339,6 +340,68 @@ public boolean trackFocus(boolean tracking) {
339340
}
340341
}
341342

343+
@Override
344+
public boolean supportsGraphemeClusterMode() {
345+
if (graphemeClusterModeSupported == null) {
346+
graphemeClusterModeSupported = probeGraphemeClusterMode();
347+
}
348+
return graphemeClusterModeSupported;
349+
}
350+
351+
/**
352+
* Probes the terminal for mode 2027 support using DECRQM.
353+
*
354+
* <p>Sends {@code CSI ? 2027 $ p} and expects a DECRPM response
355+
* {@code CSI ? 2027 ; Ps $ y} where Ps indicates the mode status.</p>
356+
*
357+
* @return {@code true} if the terminal recognizes mode 2027
358+
*/
359+
private boolean probeGraphemeClusterMode() {
360+
if (TYPE_DUMB.equals(type) || TYPE_DUMB_COLOR.equals(type)) {
361+
return false;
362+
}
363+
try {
364+
// Send DECRQM query for mode 2027
365+
writer().write("\033[?2027$p");
366+
writer().flush();
367+
368+
// Read DECRPM response: ESC [ ? 2 0 2 7 ; Ps $ y
369+
long timeout = 100;
370+
if (reader().peek(timeout) < 0) {
371+
return false;
372+
}
373+
int[] expected = {'\033', '[', '?', '2', '0', '2', '7', ';'};
374+
for (int e : expected) {
375+
if (reader().read(timeout) != e) {
376+
return false;
377+
}
378+
}
379+
int ps = reader().read(timeout);
380+
if (ps < '0' || ps > '4') {
381+
return false;
382+
}
383+
if (reader().read(timeout) != '$' || reader().read(timeout) != 'y') {
384+
return false;
385+
}
386+
// Ps: 1=set, 2=reset (can be set), 3=permanently set → supported
387+
// Ps: 0=not recognized, 4=permanently reset → not supported
388+
return ps == '1' || ps == '2' || ps == '3';
389+
} catch (IOException e) {
390+
return false;
391+
}
392+
}
393+
394+
@Override
395+
public boolean setGraphemeClusterMode(boolean enable) {
396+
if (supportsGraphemeClusterMode()) {
397+
writer().write(enable ? "\033[?2027h" : "\033[?2027l");
398+
writer().flush();
399+
return true;
400+
} else {
401+
return false;
402+
}
403+
}
404+
342405
protected void checkInterrupted() throws InterruptedIOException {
343406
if (Thread.interrupted()) {
344407
throw new InterruptedIOException();

terminal/src/main/java/org/jline/terminal/impl/AbstractWindowsTerminal.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,11 @@ public void processInputChar(char c) throws IOException {
674674
slaveInputPipe.write(c);
675675
}
676676

677+
@Override
678+
public boolean supportsGraphemeClusterMode() {
679+
return false;
680+
}
681+
677682
@Override
678683
public boolean trackMouse(MouseTracking tracking) {
679684
this.tracking = tracking;

0 commit comments

Comments
 (0)