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 55aafdf0b0a6 chore: Use non-blinking software block cursor in TUI
55aafdf0b0a6 is described below
commit 55aafdf0b0a68ac7c249134824ff12c694137645
Author: Guillaume Nodet <[email protected]>
AuthorDate: Wed Jul 8 07:11:09 2026 +0200
chore: Use non-blinking software block cursor in TUI
Replace the hardware terminal cursor in ShellPanel with a software-rendered
reversed-video cursor. The hardware cursor's blink timer resets on every
TamboUI frame redraw, causing it to appear half-shown on each keypress.
The software cursor renders the character at the cursor position with
reversed style, matching AiPanel. Also replace the blinking half-block
cursor in CaptionOverlay with a non-blinking full-block character.
Closes #24449
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
.../jbang/core/commands/tui/CaptionOverlay.java | 3 +-
.../dsl/jbang/core/commands/tui/ShellPanel.java | 57 ++++++++-----
.../jbang/core/commands/tui/ShellPanelTest.java | 93 ++++++++++++++++++++++
3 files changed, 129 insertions(+), 24 deletions(-)
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 e0db9b680868..c2eda8dfff1f 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
@@ -190,8 +190,7 @@ class CaptionOverlay {
private void renderInline(Frame frame, Rect area) {
Style style = Style.EMPTY.fg(Color.WHITE).bold();
String text = inlineBuffer != null ? inlineBuffer.toString() : "";
- boolean cursorVisible = (System.currentTimeMillis() / 500) % 2 == 0;
- String display = text + (cursorVisible ? "▌" : " ");
+ String display = text + "█";
String[] parts = display.split("\\\\n", -1);
List<Line> lines = new ArrayList<>();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
index 2f58b6ff00c0..bfb383f5d5e6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
@@ -94,8 +94,6 @@ class ShellPanel {
private int lastHeight;
private int scrollOffset;
private int lastHistorySize;
- private int lastCursorX = -1;
- private int lastCursorY = -1;
private Rect lastArea;
private volatile boolean shellExited;
@@ -286,7 +284,7 @@ class ShellPanel {
if (scrollOffset > 0) {
lines = renderScrolledView(screen, innerWidth, innerHeight);
} else {
- lines = renderLiveView(screen, innerWidth, innerHeight);
+ lines = renderLiveView(screen, innerWidth, innerHeight, cursor[0],
cursor[1]);
}
// Split the inner area: content (fill) + scrollbar (1 col) when
history exists
@@ -316,22 +314,6 @@ class ShellPanel {
.overflow(Overflow.CLIP)
.build(),
contentArea);
-
- // Position the hardware cursor only when it has moved, so the
terminal's
- // blink timer is not reset on every frame.
- if (scrollOffset == 0 && cursor[1] >= 0 && cursor[1] < innerHeight
- && cursor[0] >= 0 && cursor[0] < innerWidth) {
- int cx = contentArea.x() + cursor[0];
- int cy = contentArea.y() + cursor[1];
- if (cx != lastCursorX || cy != lastCursorY) {
- frame.setCursorPosition(cx, cy);
- lastCursorX = cx;
- lastCursorY = cy;
- }
- } else {
- lastCursorX = -1;
- lastCursorY = -1;
- }
}
void renderFooter(List<Span> spans) {
@@ -340,10 +322,11 @@ class ShellPanel {
TuiHelper.hint(spans, "PgUp/Dn", "scroll");
}
- private List<Line> renderLiveView(long[] screen, int width, int height) {
+ private List<Line> renderLiveView(long[] screen, int width, int height,
int cursorCol, int cursorRow) {
List<Line> lines = new ArrayList<>(height);
for (int row = 0; row < height; row++) {
- lines.add(convertRow(screen, row * width, width));
+ int cc = (row == cursorRow) ? cursorCol : -1;
+ lines.add(convertRow(screen, row * width, width, cc));
}
return lines;
}
@@ -351,7 +334,7 @@ class ShellPanel {
private List<Line> renderScrolledView(long[] screen, int width, int
height) {
List<long[]> history = screenTerminal.getHistory();
if (history.isEmpty()) {
- return renderLiveView(screen, width, height);
+ return renderLiveView(screen, width, height, -1, -1);
}
int totalLines = history.size() + height;
@@ -376,6 +359,22 @@ class ShellPanel {
}
static Line convertRow(long[] buffer, int offset, int width) {
+ return convertRow(buffer, offset, width, -1);
+ }
+
+ /**
+ * Converts a row of {@link ScreenTerminal} cells into a TamboUI {@link
Line}.
+ * <p>
+ * When {@code cursorCol} is non-negative, the character at that column is
rendered with reversed video to act as a
+ * software block cursor. This avoids the hardware terminal cursor whose
blink timer resets on every TamboUI frame
+ * redraw, causing the cursor to appear half-shown on each keypress.
+ *
+ * @param buffer the screen buffer (flat array of 64-bit cells)
+ * @param offset start offset of this row in the buffer
+ * @param width number of columns in this row
+ * @param cursorCol column index of the cursor in this row, or -1 for no
cursor
+ */
+ static Line convertRow(long[] buffer, int offset, int width, int
cursorCol) {
List<Span> spans = new ArrayList<>();
int col = 0;
while (col < width) {
@@ -384,10 +383,24 @@ class ShellPanel {
long attr = ScreenTerminal.cellAttr(cell);
Style style = convertCellToStyle(cell);
+ // Cursor column: emit a single reversed-video span so the cursor
+ // is always visible and does not blink (software block cursor).
+ if (col == cursorCol) {
+ spans.add(Span.styled(
+ String.valueOf(Character.toChars(ch == 0 ? ' ' : ch)),
+ style.reversed()));
+ col++;
+ continue;
+ }
+
StringBuilder sb = new StringBuilder();
sb.appendCodePoint(ch == 0 ? ' ' : ch);
int nextCol = col + 1;
while (nextCol < width) {
+ // Stop batching before the cursor column so it gets its own
span
+ if (nextCol == cursorCol) {
+ break;
+ }
long nextCell = buffer[offset + nextCol];
if (ScreenTerminal.cellAttr(nextCell) != attr) {
break;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanelTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanelTest.java
index 6c1d5d2d58ba..580e6f786680 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanelTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanelTest.java
@@ -156,6 +156,99 @@ class ShellPanelTest {
assertEquals("ABC", rawContent(line));
}
+ // ---- convertRow cursor tests ----
+
+ @Test
+ void convertRowCursorReversesCharacter() {
+ // Cursor at column 1 should reverse that character
+ long[] buffer = new long[] { 'A', 'B', 'C' };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3, 1);
+ List<Span> spans = line.spans();
+ // Should split into: "A", reversed "B", "C"
+ assertEquals(3, spans.size());
+ assertEquals("A", spans.get(0).content());
+ assertEquals("B", spans.get(1).content());
+
assertTrue(spans.get(1).style().effectiveModifiers().contains(Modifier.REVERSED));
+ assertEquals("C", spans.get(2).content());
+ }
+
+ @Test
+ void convertRowCursorAtStart() {
+ long[] buffer = new long[] { 'X', 'Y', 'Z' };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3, 0);
+ List<Span> spans = line.spans();
+ assertEquals(2, spans.size());
+ assertEquals("X", spans.get(0).content());
+
assertTrue(spans.get(0).style().effectiveModifiers().contains(Modifier.REVERSED));
+ assertEquals("YZ", spans.get(1).content());
+ }
+
+ @Test
+ void convertRowCursorAtEnd() {
+ long[] buffer = new long[] { 'A', 'B', 'C' };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3, 2);
+ List<Span> spans = line.spans();
+ assertEquals(2, spans.size());
+ assertEquals("AB", spans.get(0).content());
+ assertEquals("C", spans.get(1).content());
+
assertTrue(spans.get(1).style().effectiveModifiers().contains(Modifier.REVERSED));
+ }
+
+ @Test
+ void convertRowCursorOnNullCodepoint() {
+ // Cursor on a null codepoint (empty cell) should show reversed space
+ long[] buffer = new long[] { 'A', 0, 'C' };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3, 1);
+ List<Span> spans = line.spans();
+ assertEquals(3, spans.size());
+ assertEquals(" ", spans.get(1).content());
+
assertTrue(spans.get(1).style().effectiveModifiers().contains(Modifier.REVERSED));
+ }
+
+ @Test
+ void convertRowNoCursorWhenNegative() {
+ // cursorCol = -1 means no cursor (same as the no-arg overload)
+ long[] buffer = new long[] { 'A', 'B', 'C' };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3, -1);
+ List<Span> spans = line.spans();
+ // All same attr → single merged span, no reversed
+ assertEquals(1, spans.size());
+ assertEquals("ABC", spans.get(0).content());
+
assertFalse(spans.get(0).style().effectiveModifiers().contains(Modifier.REVERSED));
+ }
+
+ @Test
+ void convertRowCursorSplitsBoldRun() {
+ // Two bold cells with cursor on the second should split the run
+ long attr = 0x8L << 24; // Bold
+ long[] buffer = new long[] {
+ 'A' | (attr << 32),
+ 'B' | (attr << 32),
+ 'C' | (attr << 32)
+ };
+
+ Line line = ShellPanel.convertRow(buffer, 0, 3, 1);
+ List<Span> spans = line.spans();
+ assertEquals(3, spans.size());
+ // First span: bold A
+ assertEquals("A", spans.get(0).content());
+
assertTrue(spans.get(0).style().effectiveModifiers().contains(Modifier.BOLD));
+
assertFalse(spans.get(0).style().effectiveModifiers().contains(Modifier.REVERSED));
+ // Second span: bold + reversed B (cursor)
+ assertEquals("B", spans.get(1).content());
+
assertTrue(spans.get(1).style().effectiveModifiers().contains(Modifier.BOLD));
+
assertTrue(spans.get(1).style().effectiveModifiers().contains(Modifier.REVERSED));
+ // Third span: bold C
+ assertEquals("C", spans.get(2).content());
+
assertTrue(spans.get(2).style().effectiveModifiers().contains(Modifier.BOLD));
+
assertFalse(spans.get(2).style().effectiveModifiers().contains(Modifier.REVERSED));
+ }
+
// ---- encodeKeyEvent tests ----
@Test