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 eab11810f0e4 CAMEL-23841: camel-jbang - fix TUI layout overflow and 
add minimum size guard
eab11810f0e4 is described below

commit eab11810f0e474be18484e367126d4b894a5b447
Author: Adriano Machado <[email protected]>
AuthorDate: Mon Jun 29 01:43:04 2026 -0400

    CAMEL-23841: camel-jbang - fix TUI layout overflow and add minimum size 
guard
    
    Fix TUI layout issues when the terminal is too small:
    
    - Add minimum terminal size guard (88x24) with a friendly resize message
    - Use compact tab labels when terminal width is below 126 columns
    - Implement priority-based footer overflow handling that drops secondary
      F-key hints (F6, F3, F2) before primary ones when footer exceeds width
    - Extract dropFKeyHints method for correct F-key removal order
    
    Closes #24284
    
    Co-Authored-By: Claude Opus 4.7 <[email protected]>
---
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  | 177 +++++++++++++++++----
 .../jbang/core/commands/tui/CamelMonitorTest.java  | 103 ++++++++++++
 2 files changed, 252 insertions(+), 28 deletions(-)

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 f9985158d512..d8626cfe95c8 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 @@ public class CamelMonitor extends CamelCommand {
     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<Rect> mainChunks = Layout.vertical()
                 .constraints(
@@ -1066,15 +1082,62 @@ public class CamelMonitor extends CamelCommand {
                 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 @@ public class CamelMonitor extends CamelCommand {
             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 @@ public class CamelMonitor extends CamelCommand {
             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 @@ public class CamelMonitor extends CamelCommand {
             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 @@ public class CamelMonitor extends CamelCommand {
         screenshotMessage = null;
 
         List<Span> spans = new ArrayList<>();
+        int fKeyTotal = 0;
 
         if (helpOverlay.isVisible()) {
             helpOverlay.renderFooter(spans);
@@ -1684,10 +1761,10 @@ public class CamelMonitor extends CamelCommand {
             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 @@ public class CamelMonitor extends CamelCommand {
             }
         }
 
+        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 @@ public class CamelMonitor extends CamelCommand {
         frame.renderWidget(Paragraph.from(Line.from(spans)), area);
     }
 
-    private void insertFKeyHints(List<Span> spans) {
+    private int insertFKeyHints(List<Span> spans) {
         int insertPos = Math.min(2, spans.size());
         List<Span> 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 @@ public class CamelMonitor extends CamelCommand {
         }
         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<Span> 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<Span> spans) {
+    private int renderOverviewFooter(List<Span> 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 @@ public class CamelMonitor extends CamelCommand {
             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 9aa48bbc87bc..09925b08a261 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 @@ class CamelMonitorTest {
     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<Span> 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<Span> 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<Span> 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<Span> 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<Span> footer(String key, String label) {
+        List<Span> spans = new ArrayList<>();
+        hint(spans, key, label);
+        return spans;
+    }
+
+    private static int width(List<Span> spans) {
+        return spans.stream().mapToInt(Span::width).sum();
+    }
+
+    private static boolean containsKey(List<Span> spans, String key) {
+        return spans.stream().anyMatch(s -> s.content().trim().equals(key));
+    }
+
+    private static int pairWidth(List<Span> 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);
+    }
 }

Reply via email to