diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index f9985158d5127..d8626cfe95c86 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -99,6 +99,12 @@ public class CamelMonitor extends CamelCommand { private static final int MAX_TRACES = 200; private static final int NUM_TABS = 10; + // Compact tab bar (10 labels + 9 "|" dividers) needs 88 chars — that is the true minimum + private static final int MIN_WIDTH = 88; + private static final int MIN_HEIGHT = 24; + // Full tab bar (10 labels + 9 " | " dividers) needs 126 chars; use compact below that + private static final int TABS_FULL_MIN_WIDTH = 126; + // Tab indices private static final int TAB_OVERVIEW = 0; private static final int TAB_LOG = 1; @@ -968,6 +974,16 @@ private void navigateDown() { private void render(Frame frame) { Rect area = frame.area(); + if (area.width() < MIN_WIDTH || area.height() < MIN_HEIGHT) { + renderTooSmall(frame, area); + return; + } + + if (area.width() < MIN_WIDTH || area.height() < MIN_HEIGHT) { + renderTooSmall(frame, area); + return; + } + // Layout: header (1 row) + spacer (1 row) + tabs (2 rows) + spacer (1 row) + content (fill) + footer (1 row) List mainChunks = Layout.vertical() .constraints( @@ -1066,15 +1082,62 @@ private void renderHeader(Frame frame, Rect area) { area); } + private void renderTooSmall(Frame frame, Rect area) { + Style orange = Style.EMPTY.fg(Color.rgb(0xF6, 0x91, 0x23)); + Style normal = Style.EMPTY; + Style bold = Style.EMPTY.bold(); + + String line1 = "Terminal size too small:"; + String wLabel = " Width = "; + String wVal = String.valueOf(area.width()); + String hLabel = " Height = "; + String hVal = String.valueOf(area.height()); + String line2 = wLabel + wVal + hLabel + hVal; + + String line4 = "Needed for current config:"; + String line5 = " Width = " + MIN_WIDTH + " Height = " + MIN_HEIGHT; + + // 5 content lines (2 + blank + 2 + blank), center vertically + int startY = area.y() + Math.max(0, (area.height() - 5) / 2); + + int x1 = area.x() + Math.max(0, (area.width() - CharWidth.of(line1)) / 2); + frame.buffer().setString(x1, startY, line1, bold); + + int x2 = area.x() + Math.max(0, (area.width() - CharWidth.of(line2)) / 2); + int wLabelW = CharWidth.of(wLabel); + int wValW = CharWidth.of(wVal); + int hLabelW = CharWidth.of(hLabel); + frame.buffer().setString(x2, startY + 1, wLabel, normal); + frame.buffer().setString(x2 + wLabelW, startY + 1, wVal, + area.width() < MIN_WIDTH ? orange : normal); + frame.buffer().setString(x2 + wLabelW + wValW, startY + 1, hLabel, normal); + frame.buffer().setString(x2 + wLabelW + wValW + hLabelW, startY + 1, hVal, + area.height() < MIN_HEIGHT ? orange : normal); + + int x4 = area.x() + Math.max(0, (area.width() - CharWidth.of(line4)) / 2); + frame.buffer().setString(x4, startY + 3, line4, bold); + + int x5 = area.x() + Math.max(0, (area.width() - CharWidth.of(line5)) / 2); + frame.buffer().setString(x5, startY + 4, line5, normal); + } + private void renderTabs(Frame frame, Rect area) { + boolean compact = area.width() < TABS_FULL_MIN_WIDTH; + String dividerStr = compact ? "|" : " | "; + Span divider = Span.styled(dividerStr, Style.EMPTY.dim()); boolean infraSelected = isInfraSelected(); if (infraSelected) { // Infra mode: only Overview and Log tabs - Line[] labels = { - Line.from(" 1 Overview "), - Line.from(" 2 Log "), - }; + Line[] labels = compact + ? new Line[] { + Line.from("1 Overview"), + Line.from("2 Log"), + } + : new Line[] { + Line.from(" 1 Overview "), + Line.from(" 2 Log "), + }; // Map real tab index to infra tab index for highlight int infraTabIdx = tabsState.selected() == TAB_LOG ? 1 : 0; @@ -1083,7 +1146,7 @@ private void renderTabs(Frame frame, Rect area) { Tabs tabs = Tabs.builder() .titles(labels) .highlightStyle(Style.EMPTY.fg(Color.rgb(0xF6, 0x91, 0x23)).bold()) - .divider(Span.styled(" | ", Style.EMPTY.dim())) + .divider(divider) .build(); Rect labelsArea = area.height() >= 2 @@ -1093,24 +1156,37 @@ private void renderTabs(Frame frame, Rect area) { return; } - Line[] labels = { - Line.from(" 1 Overview "), - Line.from(" 2 Log "), - Line.from(" 3 Diagram "), - Line.from(routesTab.isTopMode() ? " 4 Top " : " 4 Route "), - Line.from(" 5 Endpoint "), - Line.from(" 6 HTTP "), - Line.from(" 7 Health "), - Line.from(" 8 Inspect "), - Line.from(" 9 Errors "), - Line.from(" 0 More▾ "), - }; + Line[] labels = compact + ? new Line[] { + Line.from("1 Overview"), + Line.from("2 Log"), + Line.from("3 Diagram"), + Line.from(routesTab.isTopMode() ? "4 Top " : "4 Route"), + Line.from("5 Endpoint"), + Line.from("6 HTTP"), + Line.from("7 Health"), + Line.from("8 Inspect"), + Line.from("9 Errors"), + Line.from("0 More▾"), + } + : new Line[] { + Line.from(" 1 Overview "), + Line.from(" 2 Log "), + Line.from(" 3 Diagram "), + Line.from(routesTab.isTopMode() ? " 4 Top " : " 4 Route "), + Line.from(" 5 Endpoint "), + Line.from(" 6 HTTP "), + Line.from(" 7 Health "), + Line.from(" 8 Inspect "), + Line.from(" 9 Errors "), + Line.from(" 0 More▾ "), + }; currentTabLabels = labels; Tabs tabs = Tabs.builder() .titles(labels) .highlightStyle(Style.EMPTY.fg(Color.rgb(0xF6, 0x91, 0x23)).bold()) - .divider(Span.styled(" | ", Style.EMPTY.dim())) + .divider(divider) .build(); Rect labelsArea = area.height() >= 2 @@ -1124,7 +1200,7 @@ private void renderTabs(Frame frame, Rect area) { computeTabBadges(badgeTexts, badgeStyles); int badgeY = area.y(); - int dividerW = CharWidth.of(" | "); + int dividerW = CharWidth.of(dividerStr); int tabX = 0; for (int i = 0; i < labels.length; i++) { if (i > 0) { @@ -1655,6 +1731,7 @@ private void renderFooter(Frame frame, Rect area) { screenshotMessage = null; List spans = new ArrayList<>(); + int fKeyTotal = 0; if (helpOverlay.isVisible()) { helpOverlay.renderFooter(spans); @@ -1684,10 +1761,10 @@ private void renderFooter(Frame frame, Rect area) { MonitorTab tab = activeTab(); if (tabsState.selected() == TAB_OVERVIEW) { - renderOverviewFooter(spans); + fKeyTotal = renderOverviewFooter(spans); } else { tab.renderFooter(spans); - insertFKeyHints(spans); + fKeyTotal = insertFKeyHints(spans); } } @@ -1733,9 +1810,26 @@ private void renderFooter(Frame frame, Rect area) { } } + int hintsWidth = spans.stream().mapToInt(Span::width).sum(); + int rightWidth = rightSpans.stream().mapToInt(Span::width).sum(); + int minGap = rightSpans.isEmpty() ? 0 : 1; + + if (hintsWidth + rightWidth + minGap > area.width()) { + // Drop decorative right-side content first + rightSpans.clear(); + rightWidth = 0; + minGap = 0; + // Drop secondary F-key hints (F2/F3/F6) before tab-specific action hints. + hintsWidth = dropFKeyHints(spans, fKeyTotal, hintsWidth, area.width()); + // Then drop tab-specific hints from the tail, keeping at least 4 spans + while (spans.size() > 4 && hintsWidth > area.width()) { + Span labelSpan = spans.remove(spans.size() - 1); + Span keySpan = spans.remove(spans.size() - 1); + hintsWidth -= keySpan.width() + labelSpan.width(); + } + } + if (!rightSpans.isEmpty()) { - int hintsWidth = spans.stream().mapToInt(s -> s.width()).sum(); - int rightWidth = rightSpans.stream().mapToInt(s -> s.width()).sum(); int gap = Math.max(1, area.width() - hintsWidth - rightWidth); spans.add(Span.raw(" ".repeat(gap))); spans.addAll(rightSpans); @@ -1744,11 +1838,12 @@ private void renderFooter(Frame frame, Rect area) { frame.renderWidget(Paragraph.from(Line.from(spans)), area); } - private void insertFKeyHints(List spans) { + private int insertFKeyHints(List spans) { int insertPos = Math.min(2, spans.size()); List fKeySpans = new ArrayList<>(); MonitorTab tab = activeTab(); - if (tab != null && tab.getHelpText() != null) { + boolean hasHelp = tab != null && tab.getHelpText() != null; + if (hasHelp) { hint(fKeySpans, "F1", "help"); } hint(fKeySpans, "F2", "actions"); @@ -1757,15 +1852,40 @@ private void insertFKeyHints(List spans) { } hint(fKeySpans, "F6", "shell"); spans.addAll(insertPos, fKeySpans); + // Return total F-key span count. The footer drop loop uses this to remove pairs from + // the tail (F6, then F3, F2), stopping before the first pair (F1 help when present). + return fKeySpans.size(); + } + + /** + * Drops secondary F-key hint pairs from an overflowing footer. The F-key pairs are inserted at position 2 (after + * the first tab hint), so the last pair's key span sits at index {@code fKeyTotal}. Pairs are removed from the + * tail, so F6 goes first, then F3, then F2, and the loop stops at 2 so the first pair (F1 help when present) is + * always preserved. + * + * @param spans the footer spans, mutated in place by removing dropped pairs + * @param fKeyTotal total number of F-key spans that were inserted (e.g. 8 for F1/F2/F3/F6) + * @param hintsWidth the current rendered width of {@code spans} + * @param available the available footer width + * @return the rendered width of {@code spans} after dropping + */ + static int dropFKeyHints(List spans, int fKeyTotal, int hintsWidth, int available) { + while (fKeyTotal > 2 && hintsWidth > available) { + Span labelSpan = spans.remove(fKeyTotal + 1); + Span keySpan = spans.remove(fKeyTotal); + hintsWidth -= keySpan.width() + labelSpan.width(); + fKeyTotal -= 2; + } + return hintsWidth; } - private void renderOverviewFooter(List spans) { + private int renderOverviewFooter(List spans) { if (actionsPopup.isVisible()) { actionsPopup.renderFooter(spans); - return; + return 0; } overviewTab.renderFooter(spans); - insertFKeyHints(spans); + int fKeyTotal = insertFKeyHints(spans); // Process action hints if (ctx.selectedPid != null && !isInfraSelected()) { IntegrationInfo selInfo = findSelectedIntegration(); @@ -1786,6 +1906,7 @@ private void renderOverviewFooter(List spans) { hint(spans, "x", "stop"); hint(spans, "X", "kill"); } + return fKeyTotal; } // ---- Data Loading ---- diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java index 9aa48bbc87bc5..09925b08a2614 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorTest.java @@ -16,10 +16,16 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.util.ArrayList; +import java.util.List; + +import dev.tamboui.text.Span; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import org.junit.jupiter.api.Test; +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -50,4 +56,101 @@ void questionMarkIsSuppressedWhileTextEditing() { void unrelatedKeyDoesNotOpenHelp() { assertFalse(CamelMonitor.opensHelp(KeyEvent.ofChar('x'), false), "an unrelated key must not open help"); } + + // dropFKeyHints trims an overflowing footer by removing secondary F-key hints from the tail + // (F6 first, then F3, F2). The first F-key pair must survive: F1 (help) when present, so the + // user can always reach help, even on a narrow terminal. + + @Test + void dropFKeyHintsRemovesF6BeforeF1() { + // tab hint + F1/F2/F3/F6, fKeyTotal = 8 spans + List spans = footer("Enter", "open"); + hint(spans, "F1", "help"); + hint(spans, "F2", "actions"); + hint(spans, "F3", "switch"); + hint(spans, "F6", "shell"); + int width = width(spans); + + // Available width is one pair short, so exactly one pair (F6) must be dropped. + int available = width - pairWidth(spans, "F6"); + int newWidth = CamelMonitor.dropFKeyHints(spans, 8, width, available); + + assertFalse(containsKey(spans, "F6"), "F6 (shell) must be dropped first"); + assertTrue(containsKey(spans, "F1"), "F1 (help) must be preserved"); + assertTrue(containsKey(spans, "F2"), "F2 must remain when only one pair needs dropping"); + assertTrue(containsKey(spans, "F3"), "F3 must remain when only one pair needs dropping"); + assertEquals(width(spans), newWidth, "returned width must match the remaining spans"); + } + + @Test + void dropFKeyHintsNeverDropsF1Help() { + List spans = footer("Enter", "open"); + hint(spans, "F1", "help"); + hint(spans, "F2", "actions"); + hint(spans, "F3", "switch"); + hint(spans, "F6", "shell"); + + // A tiny terminal forces every droppable pair to go. + int newWidth = CamelMonitor.dropFKeyHints(spans, 8, width(spans), 1); + + assertTrue(containsKey(spans, "F1"), "F1 (help) must never be dropped"); + assertFalse(containsKey(spans, "F2"), "F2 must be dropped under heavy overflow"); + assertFalse(containsKey(spans, "F3"), "F3 must be dropped under heavy overflow"); + assertFalse(containsKey(spans, "F6"), "F6 must be dropped under heavy overflow"); + assertTrue(containsKey(spans, "Enter"), "the leading tab hint must be preserved"); + assertEquals(width(spans), newWidth, "returned width must match the remaining spans"); + } + + @Test + void dropFKeyHintsKeepsFirstSecondaryHintWhenNoHelp() { + // No F1 (tab without help text): F2/F3/F6 only, fKeyTotal = 6 spans + List spans = footer("Enter", "open"); + hint(spans, "F2", "actions"); + hint(spans, "F3", "switch"); + hint(spans, "F6", "shell"); + + int newWidth = CamelMonitor.dropFKeyHints(spans, 6, width(spans), 1); + + assertTrue(containsKey(spans, "F2"), "first secondary hint is preserved as the loop stops at 2"); + assertFalse(containsKey(spans, "F3"), "F3 must be dropped"); + assertFalse(containsKey(spans, "F6"), "F6 must be dropped"); + assertEquals(width(spans), newWidth, "returned width must match the remaining spans"); + } + + @Test + void dropFKeyHintsLeavesFooterUntouchedWhenItFits() { + List spans = footer("Enter", "open"); + hint(spans, "F1", "help"); + hint(spans, "F2", "actions"); + int width = width(spans); + + int newWidth = CamelMonitor.dropFKeyHints(spans, 4, width, 1000); + + assertEquals(width, newWidth, "no spans dropped when the footer already fits"); + assertTrue(containsKey(spans, "F1")); + assertTrue(containsKey(spans, "F2")); + } + + private static List footer(String key, String label) { + List spans = new ArrayList<>(); + hint(spans, key, label); + return spans; + } + + private static int width(List spans) { + return spans.stream().mapToInt(Span::width).sum(); + } + + private static boolean containsKey(List spans, String key) { + return spans.stream().anyMatch(s -> s.content().trim().equals(key)); + } + + private static int pairWidth(List spans, String key) { + for (int i = 0; i + 1 < spans.size(); i++) { + if (spans.get(i).content().trim().equals(key)) { + return spans.get(i).width() + spans.get(i + 1).width(); + } + } + throw new IllegalArgumentException("no hint pair for key " + key); + } }