This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25580 in repository https://gitbox.apache.org/repos/asf/camel.git
commit e48443fb053578d9c01225e783691dffbe931f28 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Oct 13 10:05:40 2026 +0200 CAMEL-25580: camel-jbang - Upgrade TamboUI to 0.6.0 TamboUI 0.6.0 continues a wrapped paragraph line under its first non-blank character (hanging indent), and that cannot be turned off. The Log tab (follow mode), the source viewer and the process tab count the rows of their wrapped lines to scroll, and counted too few for indented lines such as stack trace lines: following a log with wrap on, the default, could hide the newest lines. TuiHelper.wrappedRows counts the rows the way the paragraph wraps a line, and replaces the three counters; a test compares it with the paragraph's own rendering. The source viewer now counts each line as drawn, with its marker and line number, which it did not do before the upgrade either, and with wrap on its scroll limit keeps the end of the file on screen. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/dsl/jbang/core/commands/tui/LogTab.java | 40 +---------- .../dsl/jbang/core/commands/tui/ProcessTab.java | 3 +- .../dsl/jbang/core/commands/tui/SourceViewer.java | 47 ++++++------ .../dsl/jbang/core/commands/tui/TuiHelper.java | 83 ++++++++++++++++++++++ .../jbang/core/commands/tui/LogTabRenderTest.java | 23 ++++-- .../commands/tui/SourceViewerWordWrapTest.java | 77 ++++++++++++++++++++ .../dsl/jbang/core/commands/tui/TuiHelperTest.java | 50 +++++++++++++ parent/pom.xml | 2 +- 8 files changed, 256 insertions(+), 69 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java index b34f0d2499ba..9cbbb0661279 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java @@ -825,42 +825,12 @@ class LogTab extends AbstractTab { Span.raw(entry.message != null ? entry.message : "")); } - /** - * The rows a line of text takes when wrapped at word boundaries into the width, as the paragraph wraps it with word - * wrap on (a word longer than the width is broken). - */ - static int wrappedRows(String text, int width) { - if (width <= 0 || text == null || text.isEmpty()) { - return 1; - } - int rows = 1; - int col = 0; - boolean first = true; - for (String word : text.split(" ", -1)) { - int w = CharWidth.of(word); - if (first) { - col = w; - first = false; - } else if (col + 1 + w <= width) { - col += 1 + w; - } else { - rows++; - col = w; - } - while (col > width) { - rows++; - col -= width; - } - } - return rows; - } - /** The first entry from which the last entries fit the height when wrapped (follow mode with word wrap). */ static int firstEntryThatFits(List<Line> lines, int height, int width) { int rows = 0; int i = lines.size(); while (i > 0) { - int r = wrappedRows(lineText(lines.get(i - 1)), width); + int r = TuiHelper.wrappedRows(lines.get(i - 1), width); if (rows + r > height) { break; } @@ -876,7 +846,7 @@ class LogTab extends AbstractTab { int rows = 0; int n = 0; for (int i = start; i < lines.size(); i++) { - int r = wrappedRows(lineText(lines.get(i)), width); + int r = TuiHelper.wrappedRows(lines.get(i), width); if (rows + r > height && n > 0) { break; } @@ -886,12 +856,6 @@ class LogTab extends AbstractTab { return n; } - private static String lineText(Line line) { - StringBuilder sb = new StringBuilder(); - line.spans().forEach(sp -> sb.append(sp.content())); - return sb.toString(); - } - private static Style levelStyle(String level) { return switch (level) { case "ERROR", "FATAL" -> Theme.error(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessTab.java index 0c38915f7304..6fc5c2cd078d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProcessTab.java @@ -168,8 +168,7 @@ class ProcessTab extends AbstractTab { if (wrap) { contentHeight = 0; for (Line l : lines) { - int w = l.width(); - contentHeight += Math.max(1, (w + visibleWidth - 1) / visibleWidth); + contentHeight += TuiHelper.wrappedRows(l, visibleWidth); } contentHeight += visibleHeight; } else { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java index 4849d8f4fa35..a0fc7fe3206f 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java @@ -2721,10 +2721,7 @@ class SourceViewer { if (selectedLine < scrollY) { scrollY = selectedLine; } else if (wordWrap) { - while (scrollY < selectedLine - && countVisualRows(scrollY, selectedLine + 1, contentWidth) > visibleLines) { - scrollY++; - } + scrollY = Math.max(scrollY, firstLineThatFits(selectedLine, visibleLines, contentWidth)); } else if (selectedLine >= scrollY + visibleLines) { scrollY = selectedLine - visibleLines + 1; } @@ -2732,15 +2729,7 @@ class SourceViewer { int maxScroll; if (wordWrap) { - maxScroll = 0; - int visualFromEnd = 0; - for (int i = lines.size() - 1; i >= 0; i--) { - visualFromEnd += wrapRowCount(lines.get(i), contentWidth); - if (visualFromEnd >= visibleLines) { - maxScroll = i; - break; - } - } + maxScroll = firstLineThatFits(lines.size() - 1, visibleLines, contentWidth); } else { maxScroll = Math.max(0, lines.size() - visibleLines); } @@ -4191,19 +4180,31 @@ class SourceViewer { return lastCommentLine >= 0 ? lastCommentLine + 1 : 0; } - private int countVisualRows(int fromLine, int toLine, int contentWidth) { - int count = 0; - for (int i = fromLine; i < toLine && i < lines.size(); i++) { - count += wrapRowCount(lines.get(i), contentWidth); + /** + * The first line from which the lines down to {@code last} fit the rows when word wrapped, or {@code last} when it + * is taller than the rows on its own. + */ + private int firstLineThatFits(int last, int rows, int contentWidth) { + if (last < 0 || last >= lines.size()) { + return 0; + } + int first = last; + int used = wrapRowCount(last, contentWidth); + while (first > 0) { + int r = wrapRowCount(first - 1, contentWidth); + if (used + r > rows) { + break; + } + used += r; + first--; } - return count; + return first; } - private int wrapRowCount(String line, int contentWidth) { - if (!wordWrap || contentWidth <= 0 || line.length() <= contentWidth) { - return 1; - } - return (line.length() + contentWidth - 1) / contentWidth; + /** The rows a line takes when word wrapped: as it is drawn, with its marker, line number and live column. */ + private int wrapRowCount(int lineIndex, int contentWidth) { + Line shown = highlightSourceLine(lines.get(lineIndex), lineIndex, 0, lineIndex == selectedLine, contentWidth + 1); + return TuiHelper.wrappedRows(shown, contentWidth); } private void scanDeprecatedLines() { 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 ce4d9bf8884d..8a9797ae5c87 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 @@ -34,6 +34,7 @@ import dev.tamboui.style.Modifier; import dev.tamboui.style.Style; import dev.tamboui.terminal.Frame; import dev.tamboui.text.CharWidth; +import dev.tamboui.text.GraphemeClusters; import dev.tamboui.text.Line; import dev.tamboui.text.Span; import dev.tamboui.widgets.scrollbar.Scrollbar; @@ -1232,6 +1233,88 @@ final class TuiHelper { return lines; } + /** + * The rows a line takes in a paragraph with word wrap ({@code Overflow.WRAP_WORD}), counted the way TamboUI 0.6 + * wraps it: at the last whitespace that fits, else after the last '-', '/' or '\', else at the width. A line that + * starts with spaces continues under its first non-blank character (hanging indent). Callers count the rows to + * scroll, so a count lower than the paragraph's hides the last rows. Replace with + * {@code Paragraph.lineCount(width)} once TamboUI has it (tamboui/tamboui#545). + */ + static int wrappedRows(String text, int width) { + if (width <= 0 || text == null || CharWidth.of(text) <= width) { + return 1; + } + int indent = 0; + while (indent < text.length() && text.charAt(indent) == ' ') { + indent++; + } + if (indent == 0 || indent == text.length() || indent >= width) { + return wordWrapRows(text, width); + } + // the first row as usual, the rest of the line under its first non-blank character + int rest = wrapRowEnd(text, 0, width); + while (rest < text.length() && Character.isWhitespace(text.charAt(rest))) { + rest++; + } + String continuation = text.substring(rest); + int continuationWidth = CharWidth.of(continuation); + if (continuationWidth == 0) { + return 1; + } + return 1 + (continuationWidth <= width - indent ? 1 : wordWrapRows(continuation, width - indent)); + } + + /** The rows a line takes in a paragraph with word wrap; see {@link #wrappedRows(String, int)}. */ + static int wrappedRows(Line line, int width) { + StringBuilder text = new StringBuilder(); + for (Span span : line.spans()) { + text.append(span.content()); + } + return wrappedRows(text.toString(), width); + } + + private static int wordWrapRows(String text, int width) { + int rows = 0; + int pos = 0; + while (pos < text.length()) { + int end = wrapRowEnd(text, pos, width); + rows++; + pos = end; + // a row that ends at whitespace: the next row starts after all of it + if (end < text.length() && Character.isWhitespace(text.codePointBefore(end))) { + while (pos < text.length() && Character.isWhitespace(text.codePointAt(pos))) { + pos += Character.charCount(text.codePointAt(pos)); + } + } + } + return rows; + } + + /** The index just past the row that starts at {@code start}, when the text wraps at word boundaries. */ + private static int wrapRowEnd(String text, int start, int width) { + String remaining = text.substring(start); + int maxEnd = start + CharWidth.substringByWidth(remaining, width).length(); + if (maxEnd >= text.length()) { + return text.length(); + } + if (maxEnd == start) { + // a character wider than the row still takes a row of its own + return start + GraphemeClusters.clusterEnd(remaining, 0); + } + for (int i = text.offsetByCodePoints(maxEnd, -1); i > start; i = text.offsetByCodePoints(i, -1)) { + if (Character.isWhitespace(text.codePointAt(i))) { + return i + 1; + } + } + for (int i = text.offsetByCodePoints(maxEnd, -1); i > start; i = text.offsetByCodePoints(i, -1)) { + char ch = text.charAt(i); + if (ch == '-' || ch == '/' || ch == '\\') { + return i + 1; + } + } + return maxEnd; + } + static String capitalize(String s) { if (s == null || s.isEmpty()) { return s; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java index 1ee718050b8a..34c7851a80a8 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabRenderTest.java @@ -114,11 +114,24 @@ class LogTabRenderTest { } @Test - void wrappedLinesAreCountedByTheirRows() { - assertEquals(1, LogTab.wrappedRows("short line", 40)); - assertEquals(2, LogTab.wrappedRows("one two three four five six seven eight nine ten", 30)); - assertEquals(3, LogTab.wrappedRows("x".repeat(70), 30)); - assertEquals(1, LogTab.wrappedRows("", 30)); + void followingWithWordWrapShowsTheNewestLineAfterAStackTrace() { + List<LogEntry> entries = new ArrayList<>(); + entries.add(LogTab.parseLogLine( + "2026-10-05 20:14:58.101 ERROR 33838 --- [mer://inventory] o.a.c.p.e.DefaultErrorHandler : Failed delivery")); + for (int i = 0; i < 20; i++) { + // a stack trace line starts with a tab, and the paragraph continues it under its "at" when it wraps + entries.add(LogTab.parseLogLine("\tat org.apache.camel.processor.errorhandler.RedeliveryErrorHandler" + + "$RedeliveryTask.run(RedeliveryErrorHandler.java:" + (1200 + i) + + ") ~[camel-core-processor-4.23.0.jar:4.23.0]")); + } + entries.add(LogTab.parseLogLine( + "2026-10-05 20:14:59.906 ERROR 33838 --- [mer://inventory] orders.camel.yaml:44 : The newest line")); + LogTab tab = new LogTab(ctx); + tab.setEntriesForTesting(entries); + + String rendered = TuiTestHelper.renderToString(tab, 83, 20); + + assertTrue(rendered.contains("The newest line"), rendered); } @Test diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerWordWrapTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerWordWrapTest.java new file mode 100644 index 000000000000..3c709e6fa6bf --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerWordWrapTest.java @@ -0,0 +1,77 @@ +/* + * 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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.terminal.Frame; +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; + +class SourceViewerWordWrapTest { + + @TempDir + Path tempDir; + + @Test + void theSelectedLineStaysOnScreenWhenLongLinesWrap() throws IOException { + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(longLines()); + viewer.handleKeyEvent(KeyEvent.ofChar('w')); + viewer.goToLine(29); + + assertThat(render(viewer)).contains("the last line"); + } + + @Test + void theSelectedLineStaysOnScreenWhenLongLinesWrapWithoutLineNumbers() throws IOException { + SourceViewer viewer = new SourceViewer(); + viewer.loadFile(longLines()); + viewer.handleKeyEvent(KeyEvent.ofChar('w')); + viewer.handleKeyEvent(KeyEvent.ofChar('p')); + viewer.goToLine(29); + + assertThat(render(viewer)).contains("the last line"); + } + + /** 30 indented lines that each wrap onto a few rows. */ + private Path longLines() throws IOException { + String words = "a long message with words that wrap ".repeat(6); + StringBuilder yaml = new StringBuilder(); + for (int i = 0; i < 29; i++) { + yaml.append(" message: \"").append(words).append(i).append("\"\n"); + } + yaml.append(" message: \"").append(words).append("the last line\"\n"); + Path file = tempDir.resolve("route.camel.yaml"); + Files.writeString(file, yaml.toString()); + return file; + } + + private static String render(SourceViewer viewer) { + Rect area = new Rect(0, 0, 80, 24); + Buffer buffer = Buffer.empty(area); + viewer.render(Frame.forTesting(buffer), area); + return TuiTestHelper.bufferToString(buffer); + } +} 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 e767da7448b9..dd5a1c9b265f 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 @@ -18,12 +18,17 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.List; +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; import dev.tamboui.style.AnsiColor; import dev.tamboui.style.Color; import dev.tamboui.style.Modifier; +import dev.tamboui.style.Overflow; import dev.tamboui.style.Style; import dev.tamboui.text.Line; import dev.tamboui.text.Span; +import dev.tamboui.text.Text; +import dev.tamboui.widgets.paragraph.Paragraph; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -275,6 +280,51 @@ class TuiHelperTest { assertEquals(List.of("one two", "three"), TuiHelper.wrapWords("one two three", 8)); } + @Test + void wrappedRowsAreTheRowsTheParagraphDraws() { + List<String> lines = List.of( + "", + "short line", + "one two three four five six seven eight nine ten", + "x".repeat(70), + // stack trace lines, their tab expanded: the paragraph continues them under the "at" + " at org.apache.camel.processor.errorhandler.RedeliveryErrorHandler$RedeliveryTask.run(" + + "RedeliveryErrorHandler.java:1234) ~[camel-core-processor-4.23.0.jar:4.23.0]", + " at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) ~[?:?]", + " Started route1 (timer://hello) with a description that goes on and on and on and on and on", + // an indent that leaves no room for text is not kept + " ".repeat(40) + "word ".repeat(20), + " ".repeat(90), + "a" + " ".repeat(90) + "b", + "/usr/local/a/long/path/to/a/file/that/is/deeply/nested/in/the/tree/route.camel.yaml", + "C:\\Users\\camel\\a\\long\\path\\to\\a\\file\\that\\is\\deeply\\nested\\route.camel.yaml", + "a-long-kebab-case-name-that-breaks-after-its-dashes-when-it-does-not-fit-on-a-row", + // wide characters: CJK, an emoji, and a family emoji joined with ZWJ + "日本語のテキストはとても長いので折り返されます。日本語のテキストはとても長いので折り返されます。", + " emoji " + "😀".repeat(30) + " end", + " " + "👩\u200D👩\u200D👧".repeat(12)); + for (int width : new int[] { 1, 2, 7, 10, 33, 60, 80 }) { + for (String line : lines) { + assertEquals(paragraphRows(line, width), TuiHelper.wrappedRows(line, width), + "width " + width + ": " + line); + } + } + } + + /** The rows the paragraph draws the line on: the row of a marker line drawn after it. */ + private static int paragraphRows(String line, int width) { + Rect area = new Rect(0, 0, width, 1000); + Buffer buffer = Buffer.empty(area); + Paragraph.builder().text(Text.from(Line.from(line), Line.from("#"))).overflow(Overflow.WRAP_WORD).build() + .render(area, buffer); + for (int y = 0; y < area.height(); y++) { + if ("#".equals(buffer.get(0, y).symbol())) { + return y; + } + } + throw new AssertionError("the marker line is not drawn"); + } + // ---- shortTypeName tests ---- @Test diff --git a/parent/pom.xml b/parent/pom.xml index 47ff9768bc2a..f95353475cff 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -531,7 +531,7 @@ <swagger-ui-version>5.33.1</swagger-ui-version> <stringtemplate-version>4.3.4</stringtemplate-version> <tahu-version>1.0.21</tahu-version> - <tamboui-version>0.5.0</tamboui-version> + <tamboui-version>0.6.0</tamboui-version> <testcontainers-version>2.0.5</testcontainers-version> <thymeleaf-version>3.1.5.RELEASE</thymeleaf-version> <tika-version>4.1.0</tika-version>
