This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch quick-fix/polish-layout in repository https://gitbox.apache.org/repos/asf/camel.git
commit b6ae0f851f84d07f861f5b926c0ef506197f3433 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Oct 1 17:41:59 2026 +0200 chore: camel-jbang - layout polish: a summary when nothing is selected, wrapped lines keep their indent, a compact log, a waterfall that is a time line, and a screenshot with the menu open - Overview: an "All" panel (running, opened, totals, heap) instead of an empty box when no integration is selected, no >> marker without a selection, and TOTAL/FAIL wide enough for "(since last)" - a wrapped detail line continues under its value or indent (Inspect, Activity, Errors details, Architecture info) - Log: a compact view, on by default (c): time, level, logger and message - Inspect waterfall: each bar starts when its step ran - the view bar ends in … when a setting does not fit - run form: the web console path of the runtime (/actuator/camel, /q/camel/dev-console, /q/dev) - tui_action screenshot captures the screen as shown, an open menu included Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../dsl/jbang/core/commands/tui/ActionsPopup.java | 6 +- .../jbang/core/commands/tui/ArchitectureView.java | 8 +- .../dsl/jbang/core/commands/tui/HistoryTab.java | 82 ++++++++++++++----- .../camel/dsl/jbang/core/commands/tui/LogTab.java | 65 +++++++++++++-- .../dsl/jbang/core/commands/tui/OverviewTab.java | 60 +++++++++++++- .../jbang/core/commands/tui/RunOptionsForm.java | 13 ++- .../dsl/jbang/core/commands/tui/SubViewBar.java | 17 ++++ .../dsl/jbang/core/commands/tui/TuiHelper.java | 92 ++++++++++++++++++++++ .../src/main/resources/tui/help/log.md | 1 + .../jbang/core/commands/tui/ActionsPopupTest.java | 14 ++++ .../jbang/core/commands/tui/HangingWrapTest.java | 75 ++++++++++++++++++ .../core/commands/tui/HistoryTabRenderTest.java | 26 ++++++ .../jbang/core/commands/tui/LogTabRenderTest.java | 8 ++ .../jbang/core/commands/tui/SubViewBarTest.java | 15 ++++ 14 files changed, 448 insertions(+), 34 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java index bf2db2b5c840..753147193168 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java @@ -1482,7 +1482,11 @@ class ActionsPopup { Theme.toggle(); refreshTheme(); } - case SCREENSHOT -> screenshotAction.run(); + case SCREENSHOT -> { + // the screen as it is shown, with an open menu or popup (for docs): nothing is closed first + screenshotAction.run(); + return true; + } case SHOW_KEYSTROKES -> toggleKeystrokes.run(); case TAPE_RECORDING -> toggleTapeRecording.run(); case DOCTOR -> doctorPopup.open(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java index 33d7d5645917..7b5dad3d9d70 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ArchitectureView.java @@ -596,8 +596,8 @@ final class ArchitectureView { section(lines, "Routes", names(g.routes()), Set.of()); section(lines, "Endpoints", g.entryPoints(), Set.of()); frame.renderWidget(Paragraph.builder() - .text(Text.from(lines)) - .overflow(Overflow.WRAP_WORD) + .text(Text.from(TuiHelper.hangingWrap(lines, Math.max(1, area.width() - 2)))) + .overflow(Overflow.CLIP) .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(" Info ").build()) .build(), area); return; @@ -619,8 +619,8 @@ final class ArchitectureView { Theme.warning()))); } frame.renderWidget(Paragraph.builder() - .text(Text.from(lines)) - .overflow(Overflow.WRAP_WORD) + .text(Text.from(TuiHelper.hangingWrap(lines, Math.max(1, area.width() - 2)))) + .overflow(Overflow.CLIP) .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(" Info ").build()) .build(), area); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java index 5d41814776ac..8338d84aace9 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java @@ -1437,22 +1437,23 @@ class HistoryTab extends AbstractTab { } record WaterfallStep(String nodeId, String processor, String direction, boolean first, boolean last, - int nodeLevel, long elapsed, int inlineDepth) { + int nodeLevel, long elapsed, int inlineDepth, long startMs) { static WaterfallStep fromTrace(TraceEntry e) { return new WaterfallStep( e.nodeId, e.processor, e.direction, e.first, e.last, e.nodeLevel, e.elapsed, - e.inlineDepth); + e.inlineDepth, e.epochMs); } static WaterfallStep fromHistory(HistoryEntry e) { return new WaterfallStep( e.nodeId, e.processor, e.direction, e.first, e.last, e.nodeLevel, e.elapsed, - e.inlineDepth); + e.inlineDepth, e.epochMs); } WaterfallStep withElapsed(long newElapsed) { - return new WaterfallStep(nodeId, processor, direction, first, last, nodeLevel, newElapsed, inlineDepth); + return new WaterfallStep( + nodeId, processor, direction, first, last, nodeLevel, newElapsed, inlineDepth, startMs); } String label() { @@ -1507,11 +1508,14 @@ class HistoryTab extends AbstractTab { return; } + long[] offsets = waterfallOffsets(forward); long maxElapsed = 0; long minDuration = Long.MAX_VALUE; long maxDuration = 0; - for (WaterfallStep e : forward) { - maxElapsed = Math.max(maxElapsed, e.elapsed); + for (int i = 0; i < forward.size(); i++) { + WaterfallStep e = forward.get(i); + // the time line: as long as the step that ends last + maxElapsed = Math.max(maxElapsed, offsets[i] + e.elapsed); if (!e.first) { minDuration = Math.min(minDuration, e.elapsed); maxDuration = Math.max(maxDuration, e.elapsed); @@ -1558,7 +1562,7 @@ class HistoryTab extends AbstractTab { int end = Math.min(waterfallScroll + visibleLines, forward.size()); List<Line> lines = new ArrayList<>(); for (int i = waterfallScroll; i < end; i++) { - lines.add(renderWaterfallStep(forward.get(i), labelWidth, barMaxWidth, + lines.add(renderWaterfallStep(forward.get(i), offsets[i], labelWidth, barMaxWidth, maxElapsed, minDuration, maxDuration, i == selectedForwardIndex)); } @@ -1577,8 +1581,50 @@ class HistoryTab extends AbstractTab { } } + /** + * When each step started, in ms from the first step processed, so the bars of a waterfall stand where they ran. A + * route row starts with its first step (its own time is when the exchange was created, before it was processed). + * Without the times of the steps, every bar starts at 0. + */ + static long[] waterfallOffsets(List<WaterfallStep> steps) { + long[] offsets = new long[steps.size()]; + // the steps processed set the time line; a route's first and last rows carry when the exchange was created + long t0 = Long.MAX_VALUE; + for (WaterfallStep s : steps) { + if (!s.first() && !s.last()) { + if (s.startMs() <= 0) { + return offsets; + } + t0 = Math.min(t0, s.startMs()); + } + } + if (t0 == Long.MAX_VALUE) { + return offsets; + } + for (int i = 0; i < steps.size(); i++) { + WaterfallStep s = steps.get(i); + if (s.first()) { + // a route starts with its first step + long start = i + 1 < steps.size() && !steps.get(i + 1).last() ? steps.get(i + 1).startMs() : t0; + offsets[i] = Math.max(0, start - t0); + } else if (s.last()) { + // the end of a route stands where the route started + offsets[i] = 0; + for (int j = i - 1; j >= 0; j--) { + if (steps.get(j).first() && nodeIdEquals(steps.get(j).nodeId(), s.nodeId())) { + offsets[i] = offsets[j]; + break; + } + } + } else { + offsets[i] = Math.max(0, s.startMs() - t0); + } + } + return offsets; + } + private static Line renderWaterfallStep( - WaterfallStep entry, int labelWidth, int maxBarWidth, + WaterfallStep entry, long offset, int labelWidth, int maxBarWidth, long maxElapsed, long minDuration, long maxDuration, boolean selected) { String indicator = selected ? ">> " : " "; String indent = " ".repeat(entry.nodeLevel); @@ -1593,8 +1639,10 @@ class HistoryTab extends AbstractTab { Style bandStyle = isRoute ? Style.EMPTY.dim() : TuiHelper.colorForDuration(entry.elapsed, minDuration, maxDuration); double ratio = maxElapsed > 0 ? (double) entry.elapsed / maxElapsed : 0; + int lead = maxElapsed > 0 ? (int) Math.round((double) offset / maxElapsed * maxBarWidth) : 0; int barWidth = Math.max(1, (int) Math.round(ratio * maxBarWidth)); - String bar = "█".repeat(barWidth); + lead = Math.min(lead, Math.max(0, maxBarWidth - barWidth)); + String bar = " ".repeat(lead) + "█".repeat(barWidth); String durationStr = entry.elapsed + "ms"; int pad = Math.max(1, 8 - durationStr.length()); @@ -2327,19 +2375,11 @@ class HistoryTab extends AbstractTab { Rect inner = block.inner(area); int visibleHeight = Math.max(1, inner.height()); int visibleWidth = Math.max(1, inner.width() - 1); - int contentHeight; if (wordWrap) { - contentHeight = 0; - for (Line l : lines) { - int w = l.width(); - contentHeight += Math.max(1, (w + visibleWidth - 1) / visibleWidth); - } - // word-wrap breaks at word boundaries which can produce more lines - // than char-based math; add padding so last section is always reachable - contentHeight += visibleHeight; - } else { - contentHeight = lines.size(); + // wrapped here, so a long header value continues under the value, not at the left edge + lines = TuiHelper.hangingWrap(lines, visibleWidth); } + int contentHeight = lines.size(); int maxScroll = Math.max(0, contentHeight - visibleHeight); if (scroll[0] > maxScroll) { scroll[0] = maxScroll; @@ -2357,7 +2397,7 @@ class HistoryTab extends AbstractTab { List<Line> visibleLines = (!wordWrap && hScroll[0] > 0) ? applyHSkip(lines, hScroll[0]) : lines; Paragraph detail = Paragraph.builder() .text(Text.from(visibleLines)) - .overflow(wordWrap ? Overflow.WRAP_WORD : Overflow.CLIP) + .overflow(Overflow.CLIP) .scroll(scroll[0]) .build(); frame.renderWidget(detail, hChunks.get(0)); 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 838ed865fbee..1a1bd3d93d42 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 @@ -93,6 +93,9 @@ class LogTab extends AbstractTab { private long evictedSeen; private boolean followMode = true; private boolean wordWrap = true; + // time, level, logger and message only: the date, pid and thread take most of a row in a terminal + private boolean compact = true; + private boolean cachedCompact; private int hScroll; private boolean showLogLevelPopup; @@ -178,6 +181,11 @@ class LogTab extends AbstractTab { hScroll = 0; return true; } + if (ke.isCharIgnoreCase('c')) { + compact = !compact; + hScroll = 0; + return true; + } if (!wordWrap) { if (ke.isLeft()) { followMode = false; @@ -340,19 +348,29 @@ class LogTab extends AbstractTab { int hSkip = wordWrap ? 0 : hScroll; boolean entriesChanged = entries != cachedLogEntries; - if (entriesChanged || hSkip != cachedLogHSkip) { + if (entriesChanged || hSkip != cachedLogHSkip || compact != cachedCompact) { cachedLogEntries = entries; cachedLogHSkip = hSkip; + cachedCompact = compact; List<Line> built = new ArrayList<>(entries.size()); int maxW = 0; for (int i = 0; i < entries.size(); i++) { LogEntry entry = entries.get(i); String raw = entry.raw != null ? entry.raw : ""; - if (!wordWrap) { - maxW = Math.max(maxW, CharWidth.of(TuiHelper.stripAnsi(raw))); + Line line; + if (compact && !entry.time.isEmpty()) { + line = compactLine(entry); + if (!wordWrap) { + maxW = Math.max(maxW, line.width()); + line = hSkip > 0 ? TuiHelper.ansiToLine(lineText(line), hSkip) : line; + } + } else { + if (!wordWrap) { + maxW = Math.max(maxW, CharWidth.of(TuiHelper.stripAnsi(raw))); + } + line = raw.indexOf('\u001B') >= 0 + ? TuiHelper.ansiToLine(raw, hSkip) : colorizePlainLog(raw, entry); } - Line line = raw.indexOf('\u001B') >= 0 - ? TuiHelper.ansiToLine(raw, hSkip) : colorizePlainLog(raw, entry); if (entry.repeat > 1) { line = line.append(Span.styled(" (x" + entry.repeat + ")", Theme.warning().bold())); } @@ -430,6 +448,7 @@ class LogTab extends AbstractTab { } search.renderSearchHints(spans); hint(spans, "w", "wrap" + (wordWrap ? " [on]" : " [off]")); + hint(spans, "c", "compact" + (compact ? " [on]" : " [off]")); if (!ctx.isInfraSelected()) { hint(spans, "l", "level"); } @@ -643,6 +662,42 @@ class LogTab extends AbstractTab { + "\\[([^]]*)]\\s+" + "(\\S+)\\s*:\\s*(.*)$"); + /** A log line in the compact view: 17:24:30.257 INFO VertxPlatformHttpServer Vert.x HttpServer started ... */ + static Line compactLine(LogEntry entry) { + String time = entry.time.length() > 12 ? entry.time.substring(entry.time.length() - 12) : entry.time; + // the time of day: the date is the same for every line of a run + int space = time.indexOf(' '); + if (space >= 0) { + time = time.substring(space + 1); + } + String logger = entry.logger != null ? TuiHelper.truncate(entry.logger, 24) : ""; + return Line.from( + Span.styled(time, DIM), + Span.raw(" "), + Span.styled(String.format("%5s", entry.level), levelStyle(entry.level)), + Span.raw(" "), + Span.styled(String.format("%-24s", logger), Style.EMPTY.fg(Theme.accent())), + Span.raw(" "), + Span.raw(entry.message != null ? entry.message : "")); + } + + 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(); + case "WARN" -> Theme.warning(); + case "INFO" -> Theme.success(); + case "DEBUG" -> Style.EMPTY.fg(Theme.accent()); + case "TRACE" -> Style.EMPTY.dim(); + default -> Style.EMPTY; + }; + } + private static Line colorizePlainLog(String raw, LogEntry entry) { String plain = TuiHelper.stripAnsi(raw); Matcher m = PID_PATTERN.matcher(plain); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java index 939b5e382764..5b9f4b10696b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java @@ -274,6 +274,10 @@ class OverviewTab extends AbstractTab { infraFocused = false; } + if (ctx.selectedPid == null && !infraFocused) { + // no integration selected: no row marked as if one were (the header and info panel say none is) + tableState.clearSelection(); + } if (ctx.selectedPid != null) { for (int i = 0; i < infos.size(); i++) { if (ctx.selectedPid.equals(infos.get(i).pid)) { @@ -515,6 +519,9 @@ class OverviewTab extends AbstractTab { long maxFailed = infos.stream().mapToLong(i -> i.failed).max().orElse(0); int tw = Math.max(numWidth(maxTotal), 6); int fw = Math.max(numWidth(maxFailed), 6); + // TOTAL and FAIL are as wide as the longest "count (since last)", so the since part is not cut + int totalColWidth = 14; + int failColWidth = 14; for (IntegrationInfo info : infos) { boolean isEven = (rowIndex++ % 2 == 0); Style rowBg = isEven ? Style.EMPTY.bg(Theme.zebra()) : Style.EMPTY; @@ -620,6 +627,8 @@ class OverviewTab extends AbstractTab { ? Line.from(Span.styled(String.format("%" + fw + "d", info.failed), failStyle), Span.styled(" (" + info.sinceLastFailed + ")", Theme.muted())) : Line.from(Span.styled(String.format("%" + fw + "d", info.failed), failStyle)); + totalColWidth = Math.max(totalColWidth, totalCell.width() + 1); + failColWidth = Math.max(failColWidth, failCell.width() + 1); rows.add(Row.from( Cell.from(info.pid), @@ -658,8 +667,8 @@ class OverviewTab extends AbstractTab { Constraint.length(10), Constraint.length(7), Constraint.length(8), - Constraint.length(14), - Constraint.length(14), + Constraint.length(totalColWidth), + Constraint.length(failColWidth), Constraint.min(20), Constraint.length(0) }; @@ -829,6 +838,44 @@ class OverviewTab extends AbstractTab { renderInfoPanel(frame, infoArea); } + /** The info panel when no integration is selected: how many run, and their messages and memory together. */ + static List<Line> summaryLines(List<IntegrationInfo> infos) { + Style dim = Theme.muted(); + int running = 0; + int projects = 0; + long total = 0; + long failed = 0; + long heap = 0; + for (IntegrationInfo info : infos) { + if (info.vanishing) { + continue; + } + if (info.phantom) { + projects++; + continue; + } + running++; + total += info.exchangesTotal; + failed += info.failed; + heap += Math.max(0, info.heapMemUsed); + } + List<Line> lines = new ArrayList<>(); + lines.add(Line.from(Span.styled("Running: ", dim), Span.raw(String.valueOf(running)))); + if (projects > 0) { + lines.add(Line.from(Span.styled("Opened: ", dim), Span.raw(projects + " not running"))); + } + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from(Span.styled("Total: ", dim), Span.raw(String.valueOf(total)))); + lines.add(Line.from(Span.styled("Failed: ", dim), + Span.styled(String.valueOf(failed), failed > 0 ? Theme.error() : Style.EMPTY))); + if (heap > 0) { + lines.add(Line.from(Span.styled("Heap: ", dim), Span.raw(TuiHelper.formatBytes(heap)))); + } + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from(Span.styled("\u2191\u2193", Theme.hintKey()), Span.styled(" select one", dim))); + return lines; + } + /** How long an opened project shows as Starting when its app does not show up. */ static final long PROJECT_START_MS = 5 * 60_000; @@ -890,6 +937,15 @@ class OverviewTab extends AbstractTab { sel = active.get(0); } } + if (sel == null) { + // nothing selected: what runs, in numbers, rather than an empty box + Block allBlock = Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(Title.from(Line.from(Span.styled(" All ", Theme.title())))).build(); + frame.renderWidget(allBlock, area); + frame.renderWidget(Paragraph.builder().text(Text.from(summaryLines(ctx.data.get()))).build(), + allBlock.inner(area)); + return; + } Block infoBlock = Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).build(); frame.renderWidget(infoBlock, area); Rect inner = infoBlock.inner(area); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java index b463e69d6f4e..21d8db4f4784 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java @@ -77,6 +77,16 @@ class RunOptionsForm { TuiIcons.labeled(TuiIcons.JBANG, "JBang") }; private static final String[] RUNTIME_VALUES = { "camel-main", "spring-boot", "quarkus", "jbang" }; + + /** Where each runtime serves the developer console: each keeps its own default path. */ + static String consolePath(int runtime) { + return switch (runtime) { + case 1 -> "/actuator/camel"; + case 2 -> "/q/camel/dev-console"; + default -> "/q/dev"; + }; + } + private static final String[] PROFILE_LABELS = { TuiIcons.labeled(TuiIcons.DEV_PROFILE, "dev"), TuiIcons.labeled(TuiIcons.PROD_PROFILE, "prod") @@ -567,7 +577,8 @@ class RunOptionsForm { renderTextInput(frame, innerX + labelW, rowY, fieldW, maxInput, selectedRow == ROW_MAX); rowY++; - renderCheckbox(frame, innerX, rowY, innerW, "Web console (/q/dev)", webConsole, selectedRow == ROW_CONSOLE); + renderCheckbox(frame, innerX, rowY, innerW, "Web console (" + consolePath(runtimeMode) + ")", webConsole, + selectedRow == ROW_CONSOLE); rowY++; if (runtimeLocked) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java index 4867202f8045..c559fabd1e7b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBar.java @@ -61,6 +61,8 @@ final class SubViewBar { * @param cycleKey the key that moves through the views, or null * @param levels whether the views are levels of one another (separated by ›) rather than peers (│) */ + private static final String MORE = "\u2026 "; + record Spec(String cycleKey, List<View> views, List<Toggle> toggles, boolean levels) { } @@ -108,6 +110,7 @@ final class SubViewBar { List<int[]> rightBoxes = new ArrayList<>(); List<String> rightKeys = new ArrayList<>(); int rightWidth = 0; + boolean dropped = false; for (Toggle t : spec.toggles()) { // an on/off setting shows its state as a mark, a setting with modes names the mode boolean onOff = "on".equals(t.state()) || "off".equals(t.state()); @@ -116,6 +119,7 @@ final class SubViewBar { String mark = (onOff ? (t.on() ? "●" : "○") : "") + " "; int w = CharWidth.of(chip) + CharWidth.of(label) + CharWidth.of(mark); if (used + rightWidth + w + 2 > area.width()) { + dropped = true; break; } right.add(Span.styled(chip, Theme.hintKey())); @@ -125,6 +129,19 @@ final class SubViewBar { rightKeys.add(t.key()); rightWidth += w; } + if (dropped) { + // a setting that does not fit is not dropped silently: a … says there is more (F1 lists them all) + while (!rightBoxes.isEmpty() && used + rightWidth + MORE.length() > area.width()) { + int[] last = rightBoxes.remove(rightBoxes.size() - 1); + rightKeys.remove(rightKeys.size() - 1); + right.subList(right.size() - 3, right.size()).clear(); + rightWidth = last[0]; + } + if (used + rightWidth + MORE.length() <= area.width()) { + right.add(Span.styled(MORE, Theme.muted())); + rightWidth += MORE.length(); + } + } if (!right.isEmpty()) { int start = area.x() + area.width() - rightWidth; spans.add(Span.raw(" ".repeat(area.width() - used - rightWidth))); 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 209997c8038c..e45a3810575d 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 @@ -586,6 +586,98 @@ final class TuiHelper { return seconds + "s" + (ms % 1000) + "ms"; } + /** + * Wraps lines to a width with a hanging indent: the continuation of a "key = value" line starts under the value, + * and of an indented line under its first character, where a plain word wrap would start it at the left edge. + */ + static List<Line> hangingWrap(List<Line> lines, int width) { + List<Line> out = new ArrayList<>(lines.size()); + for (Line line : lines) { + if (width < 8 || line.width() <= width) { + out.add(line); + } else { + wrapLine(line, width, out); + } + } + return out; + } + + private record StyledChar(String text, Style style, int width) { + } + + private static void wrapLine(Line line, int width, List<Line> out) { + List<StyledChar> chars = new ArrayList<>(); + StringBuilder plain = new StringBuilder(); + for (Span span : line.spans()) { + String content = span.content(); + 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)))); + plain.append(ch); + i += Character.charCount(cp); + } + } + String text = plain.toString(); + int indent = 0; + int eq = text.indexOf(" = "); + if (eq > 0 && eq + 3 < width * 3 / 5) { + indent = eq + 3; + } else { + while (indent < text.length() && text.charAt(indent) == ' ') { + indent++; + } + } + indent = Math.min(indent, width / 2); + + int start = 0; + boolean first = true; + while (start < chars.size()) { + int room = first ? width : width - indent; + int used = 0; + int end = start; + int lastSpace = -1; + while (end < chars.size() && used + chars.get(end).width() <= room) { + if (" ".equals(chars.get(end).text())) { + lastSpace = end; + } + used += chars.get(end).width(); + end++; + } + if (end < chars.size() && lastSpace > start) { + // break after the last space that fits, so a word is not cut + end = lastSpace + 1; + } else if (end == start) { + end = start + 1; + } + List<Span> spans = new ArrayList<>(); + if (!first && indent > 0) { + spans.add(Span.raw(" ".repeat(indent))); + } + StringBuilder run = new StringBuilder(); + Style runStyle = null; + for (int i = start; i < end; i++) { + StyledChar c = chars.get(i); + if (runStyle != null && !runStyle.equals(c.style())) { + spans.add(Span.styled(run.toString(), runStyle)); + run.setLength(0); + } + runStyle = c.style(); + run.append(c.text()); + } + if (!run.isEmpty()) { + spans.add(Span.styled(run.toString(), runStyle)); + } + out.add(Line.from(spans)); + start = end; + // a continuation does not start with the spaces it broke at + while (start < chars.size() && " ".equals(chars.get(start).text())) { + start++; + } + first = false; + } + } + static String formatLoad(String l1, String l5, String l15) { String s1 = l1 != null && !"0.00".equals(l1) ? l1 : "0"; String s5 = l5 != null && !"0.00".equals(l5) ? l5 : "0"; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md index 881253076328..b789890600a0 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/log.md @@ -93,5 +93,6 @@ with the mouse. - `l` — change log level - `f` — toggle follow mode (`End` turns it on). Scrolling with the mouse wheel, `Up`, `PgUp` or `Home` turns it off; the title then shows `(paused, End follows)` when new lines arrive below the view - `w` — toggle word wrap +- `c` — toggle the compact view (on by default): the time, level, logger and message of each line, without the date, pid and thread - `Ctrl+L` — pin/cycle/unpin log panel (works from any tab) - `Esc` — clear find / back diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java index 8ebda7b7aed2..34bf2cd6bdde 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java @@ -51,6 +51,20 @@ class ActionsPopupTest { assertEquals(ActionsPopup.Action.QUIT, ActionsPopup.actionForLabel("exit")); } + @Test + void aScreenshotKeepsTheMenuOpenSoItIsInThePicture() { + AtomicBoolean shot = new AtomicBoolean(); + ActionsPopup popup = new ActionsPopup( + Set::of, List::of, List::of, null, () -> shot.set(true), () -> { + }, () -> false, () -> { + }, () -> false, () -> { + }, new HashSet<>()); + popup.open(); + assertTrue(popup.executeActionByName("screenshot")); + assertTrue(shot.get()); + assertTrue(popup.isVisible(), "the menu stays, as it was on the screen"); + } + @Test void quitIsTheLastMenuEntryAndRunsTheWiredAction() { ActionsPopup popup = new ActionsPopup( diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HangingWrapTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HangingWrapTest.java new file mode 100644 index 000000000000..1c3b2486332f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HangingWrapTest.java @@ -0,0 +1,75 @@ +/* + * 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.util.List; + +import dev.tamboui.style.Style; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A wrapped detail line continues under its value or its indent, not at the left edge. + */ +class HangingWrapTest { + + @Test + void aHeaderValueContinuesUnderTheValue() { + Line header = Line.from(Span.styled(" CamelFileAbsolutePath", Theme.muted()), + Span.raw(" = /private/var/folders/33/4s84/T/camel-example-75917/orders/order-1003.json")); + List<String> rows = text(TuiHelper.hangingWrap(List.of(header), 60)); + + assertThat(rows).hasSizeGreaterThan(1); + int value = rows.get(0).indexOf("= ") + 2; + assertThat(rows.get(1)).startsWith(" ".repeat(value)).doesNotStartWith(" ".repeat(value + 1)); + assertThat(String.join("", rows).replace(" ", "")) + .isEqualTo(text(List.of(header)).get(0).replace(" ", "")); + assertThat(rows).allMatch(r -> r.length() <= 60); + } + + @Test + void anIndentedLineContinuesUnderItsIndent() { + Line hint = Line.from(Span.raw(" not grouped yet: /overview groups the routes into capabilities")); + List<String> rows = text(TuiHelper.hangingWrap(List.of(hint), 30)); + assertThat(rows.get(0)).startsWith(" not grouped"); + assertThat(rows.subList(1, rows.size())).allMatch(r -> r.startsWith(" ") && !r.startsWith(" ")); + } + + @Test + void stylesAreKeptAndShortLinesUntouched() { + Style red = Theme.error(); + Line line = Line.from(Span.styled("error: ", red), Span.raw("a b c d e f g h i j k l m n o p q r s t u v w")); + List<Line> wrapped = TuiHelper.hangingWrap(List.of(line), 20); + assertThat(wrapped.get(0).spans().get(0).style()).isEqualTo(red); + + Line shortLine = Line.from(Span.raw("short")); + assertThat(TuiHelper.hangingWrap(List.of(shortLine), 20).get(0)).isSameAs(shortLine); + } + + @Test + void aWordLongerThanTheRowIsCut() { + List<String> rows = text(TuiHelper.hangingWrap(List.of(Line.from(Span.raw("x".repeat(50)))), 20)); + assertThat(rows).containsExactly("x".repeat(20), "x".repeat(20), "x".repeat(10)); + } + + private static List<String> text(List<Line> lines) { + return lines.stream().map(l -> l.spans().stream().map(Span::content).reduce("", String::concat)).toList(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java index ee5ebb15c425..76305b44c6f6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTabRenderTest.java @@ -17,6 +17,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -207,4 +208,29 @@ class HistoryTabRenderTest { e.last = last; return e; } + + @Test + void theWaterfallBarsStandWhereTheStepsRan() { + // the exchange was created 2s before it was processed: the route row starts with its first step + // the last row of a route carries the creation time too: it stands where its route started + List<HistoryTab.WaterfallStep> steps = List.of( + step("from1", true, false, 17, 1_000), + step("unmarshal1", false, false, 0, 3_000), + step("to2", false, false, 16, 3_001), + step("from2", true, false, 12, 1_000), + step("log3", false, false, 0, 3_005), + step("from2", false, true, 12, 1_000), + step("from1", false, true, 17, 1_000)); + long[] offsets = HistoryTab.waterfallOffsets(steps); + assertTrue(Arrays.equals(new long[] { 0, 0, 1, 5, 5, 5, 0 }, offsets), Arrays.toString(offsets)); + + // without the times of the steps, every bar starts at 0 + long[] none = HistoryTab.waterfallOffsets( + List.of(step("from1", true, false, 5, 0), step("log1", false, false, 5, 0))); + assertTrue(Arrays.equals(new long[] { 0, 0 }, none)); + } + + private static HistoryTab.WaterfallStep step(String id, boolean first, boolean last, long elapsed, long start) { + return new HistoryTab.WaterfallStep(id, id, "-->", first, last, 0, elapsed, 0, start); + } } 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 8d3b479a5bfb..ddb156189ecb 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 @@ -67,6 +67,14 @@ class LogTabRenderTest { assertEquals(1, entries.get(0).repeat, "a line that happened once is not a repeat"); } + @Test + void theCompactViewShowsTimeLevelLoggerAndMessage() { + LogEntry entry = LogTab.parseLogLine("2026-10-01 17:24:30.257 INFO 85013 --- [ntloop-thread-0]" + + " tform.http.vertx.VertxPlatformHttpServer : Vert.x HttpServer started on 0.0.0.0:8080"); + String text = LogTab.compactLine(entry).spans().stream().map(Span::content).reduce("", String::concat); + assertEquals("17:24:30.257 INFO VertxPlatformHttpServer Vert.x HttpServer started on 0.0.0.0:8080", text); + } + @Test void renderNoSelectionShowsPrompt() { ctx.selectedPid = null; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java index 7216366dc5f0..3ed6917aad1d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SubViewBarTest.java @@ -68,6 +68,21 @@ class SubViewBarTest { assertThat(line).contains(" b view: business ", " e external: edges ", " m metrics ○"); } + @Test + void settingsThatDoNotFitEndInAnEllipsis() { + SubViewBar bar = new SubViewBar(); + Rect area = new Rect(0, 0, 90, 1); + Buffer buffer = Buffer.empty(area); + bar.render(Frame.forTesting(buffer), area, levels()); + String line = TuiTestHelper.bufferToString(buffer).split("\n")[0]; + + assertThat(line).contains(" b view: business ").doesNotContain("metrics"); + assertThat(line.stripTrailing()).endsWith("…"); + assertThat(line.length()).isLessThanOrEqualTo(90); + // all fit on a wide bar: no ellipsis + assertThat(render(bar, levels(), 0)).doesNotContain("…"); + } + @Test void aClickGoesToAViewOrPressesTheKeyOfASetting() { SubViewBar bar = new SubViewBar();
