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 52c7642c8a28 CAMEL-23831: Add footer and Actions popup mouse support 
to camel-tui
52c7642c8a28 is described below

commit 52c7642c8a282490e8c193d724d5e0a1374d5e91
Author: Adriano Machado <[email protected]>
AuthorDate: Thu Jul 2 12:15:22 2026 -0400

    CAMEL-23831: Add footer and Actions popup mouse support to camel-tui
    
    Follow-up to #24351: adds the two mouse interactions it did not cover.
    
    Footer key bindings: renderFooter captures the on-screen column range of 
every
    clickable hint after the overflow-drop logic runs. A left click on the 
footer
    row hit-tests the regions and feeds a synthesized KeyEvent back through the
    normal key path. Ambiguous multi-key hints (Up/Down, PgUp/PgDn, arrow 
glyphs)
    stay non-clickable. Overlay states leave the footer non-clickable.
    
    Actions popup and doc picker: handleMouseEvent on ActionsPopup handles the 
F2
    Actions menu and doc picker with the same modal approach as the More 
dropdown.
    A click on an entry selects and activates via synthetic Enter, a click 
outside
    dismisses via synthetic Esc, and the wheel moves the highlight.
    
    Overview divider fix: a click on the Dev/Infra Services divider row now
    restores the prior selection instead of moving onto it, matching the 
keyboard
    navigation which already skips it.
    
    Closes #24365
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../dsl/jbang/core/commands/tui/ActionsPopup.java  |  81 ++++++++++++
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  | 137 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/OverviewTab.java   |  19 ++-
 .../jbang/core/commands/tui/ActionsPopupTest.java  |  70 +++++++++++
 .../jbang/core/commands/tui/CamelMonitorTest.java  |  46 +++++++
 .../core/commands/tui/OverviewTabRenderTest.java   |  70 +++++++++++
 6 files changed, 419 insertions(+), 4 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 bf20c5adb32a..1db22ce2598f 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
@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.IntPredicate;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
@@ -43,6 +44,8 @@ import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
 import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.MouseEvent;
+import dev.tamboui.tui.event.MouseEventKind;
 import dev.tamboui.widgets.Clear;
 import dev.tamboui.widgets.block.Block;
 import dev.tamboui.widgets.block.BorderType;
@@ -116,6 +119,9 @@ class ActionsPopup {
 
     private boolean showActionsMenu;
     private final ListState actionsMenuState = new ListState();
+    // Absolute bounds of the single-line list popups captured during render, 
used to hit-test clicks.
+    private Rect actionsMenuRect;
+    private Rect docPickerRect;
 
     private boolean showExampleBrowser;
     private final ListState exampleBrowserState = new ListState();
@@ -652,6 +658,79 @@ class ActionsPopup {
         return false;
     }
 
+    // ---- Mouse handling ----
+
+    /**
+     * Handles a mouse event while an Actions sub-popup is open. The Actions 
menu and the doc picker (both single-line
+     * lists) accept clicks: a click on an entry selects it and activates it 
via a synthetic Enter (reusing the keyboard
+     * activation path), a click outside goes back one level via a synthetic 
Esc, and the wheel moves the highlight.
+     * Every other sub-popup stays modal for the mouse (events are consumed 
but not acted on). Returns {@code false}
+     * only when no Actions popup is open, so the caller can fall back to its 
normal routing.
+     */
+    boolean handleMouseEvent(MouseEvent me) {
+        if (!isVisible()) {
+            return false;
+        }
+        if (showActionsMenu) {
+            return handleListPopupMouse(me, actionsMenuRect, actionsMenuState, 
visualActionCount(), this::isDividerIndex);
+        }
+        if (showDocPicker && docPickerIntegrations != null) {
+            return handleListPopupMouse(me, docPickerRect, docPickerState, 
docPickerIntegrations.size(), i -> false);
+        }
+        // Other sub-popups (forms, browsers, viewers) stay modal: consume the 
event without acting on it.
+        return true;
+    }
+
+    private boolean handleListPopupMouse(MouseEvent me, Rect rect, ListState 
state, int itemCount, IntPredicate separator) {
+        if (me.kind() == MouseEventKind.SCROLL_UP) {
+            handleKeyEvent(KeyEvent.ofKey(KeyCode.UP));
+            return true;
+        }
+        if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+            handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN));
+            return true;
+        }
+        if (me.isClick()) {
+            if (rect != null && rect.contains(me.x(), me.y())) {
+                int idx = listItemAt(rect, state.offset(), itemCount, me.x(), 
me.y());
+                if (idx >= 0 && !separator.test(idx)) {
+                    state.select(idx);
+                    handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+                }
+                // A click on the border or a divider inside the popup is 
consumed without acting.
+                return true;
+            }
+            // A click outside the popup dismisses it (one level back), 
mirroring Esc.
+            handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE));
+            return true;
+        }
+        // Consume any other mouse event so the popup stays modal.
+        return true;
+    }
+
+    /**
+     * Returns the index of the single-line list entry at {@code (mouseX, 
mouseY)} for a bordered list popup, or
+     * {@code -1} when the click is on the border, outside the popup, or past 
the last entry. {@code offset} is the
+     * index of the first visible entry (from {@code ListState.offset()}) for 
scrolled lists.
+     */
+    static int listItemAt(Rect popup, int offset, int itemCount, int mouseX, 
int mouseY) {
+        if (popup == null) {
+            return -1;
+        }
+        int innerLeft = popup.x() + 1;
+        int innerRight = popup.x() + popup.width() - 1; // exclusive: last 
column is the right border
+        int firstRow = popup.y() + 1;
+        int lastRow = popup.y() + popup.height() - 1; // exclusive: last row 
is the bottom border
+        if (mouseX < innerLeft || mouseX >= innerRight) {
+            return -1;
+        }
+        if (mouseY < firstRow || mouseY >= lastRow) {
+            return -1;
+        }
+        int idx = offset + (mouseY - firstRow);
+        return idx >= 0 && idx < itemCount ? idx : -1;
+    }
+
     void render(Frame frame, Rect area) {
         if (showActionsMenu) {
             renderActionsMenu(frame, area);
@@ -788,6 +867,7 @@ class ActionsPopup {
         int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
         int y = area.top() + 2;
         Rect popup = new Rect(x, y, Math.min(popupW, area.width()), 
Math.min(popupH, area.height() - 2));
+        this.actionsMenuRect = popup;
 
         frame.renderWidget(Clear.INSTANCE, popup);
         String divider = "  ─────────────────────────────────";
@@ -972,6 +1052,7 @@ class ActionsPopup {
         int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
         int y = area.top() + 2;
         Rect popup = new Rect(x, y, Math.min(popupW, area.width()), 
Math.min(popupH, area.height() - 2));
+        this.docPickerRect = popup;
 
         frame.renderWidget(Clear.INSTANCE, popup);
         List<ListItem> items = new ArrayList<>();
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 4241b9652446..23c511ac7e91 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
@@ -47,6 +47,7 @@ import dev.tamboui.tui.TuiRunner;
 import dev.tamboui.tui.event.Event;
 import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
 import dev.tamboui.tui.event.MouseEvent;
 import dev.tamboui.tui.event.MouseEventKind;
 import dev.tamboui.tui.event.PasteEvent;
@@ -140,6 +141,12 @@ public class CamelMonitor extends CamelCommand {
     private String lastTabDivider;
     // Panel resize drag state
     private final DragSplit panelSplit = new DragSplit();
+    // Footer key-binding hit-testing: each clickable hint records its 
[startX, endX) column range on
+    // the footer row and the KeyEvent to synthesize when clicked.
+    private int footerRowY = -1;
+    private int[] footerRegionStartX = new int[0];
+    private int[] footerRegionEndX = new int[0];
+    private KeyEvent[] footerRegionKey = new KeyEvent[0];
 
     private final ClassLoader classLoader;
 
@@ -506,7 +513,7 @@ public class CamelMonitor extends CamelCommand {
             if (aiPanel.isOpen() && aiPanel.handleMouseEvent(me)) {
                 return true;
             }
-            if (handleMouseEvent(me)) {
+            if (handleMouseEvent(me, runner)) {
                 return true;
             }
         }
@@ -752,7 +759,7 @@ public class CamelMonitor extends CamelCommand {
         return false;
     }
 
-    private boolean handleMouseEvent(MouseEvent me) {
+    private boolean handleMouseEvent(MouseEvent me, TuiRunner runner) {
         // Panel border drag resize: detect press on the border row, then 
track drag
         if (lastContentArea != null && (shellPanel.isOpen() || 
aiPanel.isOpen())
                 && panelSplit.handleMouse(me, me.y())) {
@@ -789,12 +796,20 @@ public class CamelMonitor extends CamelCommand {
             }
         }
 
+        // Footer key-binding clicks: a click on a hint fires the matching key
+        if (me.isClick() && handleFooterClick(me, runner)) {
+            return true;
+        }
+
         // Mouse events in the content area: delegate to the active tab
         if (TuiHelper.contains(lastContentArea, me.x(), me.y())) {
             if (popupManager.isMorePopupVisible() || 
popupManager.isSwitchPopupVisible()) {
                 return popupManager.handleMouseEvent(me, 
tabRegistry.selectedTabIndex(), TAB_LOG);
             }
-            if (filesBrowser.isVisible() || actionsPopup.isVisible()) {
+            if (actionsPopup.isVisible()) {
+                return actionsPopup.handleMouseEvent(me);
+            }
+            if (filesBrowser.isVisible()) {
                 return false;
             }
             MonitorTab activeTab = tabRegistry.activeTab();
@@ -840,6 +855,117 @@ public class CamelMonitor extends CamelCommand {
         return -1;
     }
 
+    /**
+     * Records the clickable footer key-binding regions from the final list of 
footer spans. A hint is drawn by
+     * {@link TuiHelper#hint} as a key span styled with {@link 
Theme#hintKey()} immediately followed by its label span,
+     * so each key span styled that way (whose token maps to an unambiguous 
single key) contributes a clickable region
+     * covering both the key and its label. {@code area} is the single footer 
row.
+     */
+    private void captureFooterRegions(Rect area, List<Span> spans) {
+        List<int[]> bounds = new ArrayList<>();
+        List<KeyEvent> keys = new ArrayList<>();
+        Style hintKeyStyle = Theme.hintKey();
+        int x = area.x();
+        for (int i = 0; i < spans.size(); i++) {
+            Span s = spans.get(i);
+            int w = s.width();
+            if (hintKeyStyle.equals(s.style())) {
+                KeyEvent ke = footerKeyEvent(s.content());
+                if (ke != null) {
+                    // Extend the region over the following label span so 
clicking the label works too.
+                    int end = x + w + (i + 1 < spans.size() ? spans.get(i + 
1).width() : 0);
+                    bounds.add(new int[] { x, end });
+                    keys.add(ke);
+                }
+            }
+            x += w;
+        }
+        footerRowY = area.y();
+        footerRegionStartX = bounds.stream().mapToInt(b -> b[0]).toArray();
+        footerRegionEndX = bounds.stream().mapToInt(b -> b[1]).toArray();
+        footerRegionKey = keys.toArray(new KeyEvent[0]);
+    }
+
+    /**
+     * Hit-tests a click against the footer key-binding regions captured 
during the last render and, when it lands on a
+     * hint, feeds the synthesized key back through the normal key path so it 
acts exactly like pressing that key.
+     */
+    private boolean handleFooterClick(MouseEvent me, TuiRunner runner) {
+        if (footerRowY < 0 || me.y() != footerRowY) {
+            return false;
+        }
+        int idx = footerRegionAt(footerRegionStartX, footerRegionEndX, me.x());
+        if (idx < 0) {
+            return false;
+        }
+        return handleEvent(footerRegionKey[idx], runner);
+    }
+
+    /**
+     * Returns the index of the footer hint region whose column range contains 
{@code mouseX}, or {@code -1} when the
+     * click is outside every region. Each region spans the half-open range 
{@code [startX[i], endX[i])}.
+     */
+    static int footerRegionAt(int[] startX, int[] endX, int mouseX) {
+        if (startX == null) {
+            return -1;
+        }
+        for (int i = 0; i < startX.length; i++) {
+            if (mouseX >= startX[i] && mouseX < endX[i]) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Maps a footer hint key token (the trimmed content of a {@link 
Theme#hintKey()} span) to the {@link KeyEvent} that
+     * pressing it would produce, or {@code null} when the token is not an 
unambiguous single key. Recognizes the
+     * function keys {@code F1}-{@code F12}, {@code Enter}, {@code Esc}, 
{@code Tab} and any single character; ambiguous
+     * multi-key hints such as {@code Up/Down}, {@code PgUp/PgDn} or arrow 
glyphs are left non-clickable.
+     */
+    static KeyEvent footerKeyEvent(String token) {
+        if (token == null) {
+            return null;
+        }
+        String t = token.trim();
+        if (t.isEmpty()) {
+            return null;
+        }
+        if (t.length() >= 2 && (t.charAt(0) == 'F' || t.charAt(0) == 'f')
+                && t.chars().skip(1).allMatch(Character::isDigit)) {
+            KeyCode fkey = functionKeyCode(Integer.parseInt(t.substring(1)));
+            return fkey != null ? KeyEvent.ofKey(fkey) : null;
+        }
+        switch (t) {
+            case "Enter":
+                return KeyEvent.ofKey(KeyCode.ENTER);
+            case "Esc":
+                return KeyEvent.ofKey(KeyCode.ESCAPE);
+            case "Tab":
+                return KeyEvent.ofKey(KeyCode.TAB);
+            default:
+                return t.length() == 1 ? KeyEvent.ofChar(t.charAt(0), 
KeyModifiers.NONE) : null;
+        }
+    }
+
+    private static KeyCode functionKeyCode(int n) {
+        return switch (n) {
+            case 1 -> KeyCode.F1;
+            case 2 -> KeyCode.F2;
+            case 3 -> KeyCode.F3;
+            case 4 -> KeyCode.F4;
+            case 5 -> KeyCode.F5;
+            case 6 -> KeyCode.F6;
+            case 7 -> KeyCode.F7;
+            case 8 -> KeyCode.F8;
+            case 9 -> KeyCode.F9;
+            case 10 -> KeyCode.F10;
+            case 11 -> KeyCode.F11;
+            case 12 -> KeyCode.F12;
+            default -> null;
+        };
+    }
+
     private boolean handlePasteEvent(PasteEvent pe) {
         if (actionsPopup.isVisible()) {
             actionsPopup.handlePaste(pe.text());
@@ -1478,6 +1604,10 @@ public class CamelMonitor extends CamelCommand {
     }
 
     private void renderFooter(Frame frame, Rect area) {
+        // Disable footer key-binding clicks until the normal path below 
re-captures the hint regions.
+        // Overlay/early-return states (screenshot flash, help, caption) leave 
the footer non-clickable.
+        footerRowY = -1;
+
         // Show screenshot flash message briefly
         String msg = recordingManager.screenshotFlashMessage();
         if (msg != null) {
@@ -1595,6 +1725,7 @@ public class CamelMonitor extends CamelCommand {
             spans.addAll(rightSpans);
         }
 
+        captureFooterRegions(area, spans);
         frame.renderWidget(Paragraph.from(Line.from(spans)), area);
     }
 
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 af1ea4c72b7f..587f74e95a48 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
@@ -170,6 +170,14 @@ class OverviewTab extends AbstractTab {
         return false;
     }
 
+    /**
+     * Returns the table area captured during the last render, or {@code null} 
before the first render. Package-private
+     * for click hit-testing in tests.
+     */
+    Rect getTableArea() {
+        return lastTableArea;
+    }
+
     @Override
     public boolean handleMouseEvent(MouseEvent me, Rect area) {
         if (vSplit.handleMouse(me, me.y())) {
@@ -178,8 +186,17 @@ class OverviewTab extends AbstractTab {
             }
             return true;
         }
+        Integer before = tableState.selected();
         if (handleTableClick(me, lastTableArea, tableState, totalRows())) {
-            syncSelectedPid();
+            // The Dev/Infra divider row is not selectable; restore the prior 
selection when it is clicked.
+            Integer sel = tableState.selected();
+            if (sel != null && dividerIndex >= 0 && sel == dividerIndex) {
+                if (before != null) {
+                    tableState.select(before);
+                }
+            } else {
+                syncSelectedPid();
+            }
             return true;
         }
         return false;
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
new file mode 100644
index 000000000000..a3d7ff6cd254
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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 dev.tamboui.layout.Rect;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests for {@link ActionsPopup#listItemAt}, which maps a click to an entry 
in a single-line, bordered list popup. The
+ * popup has a one-cell border on every side, so the first entry sits at 
{@code popup.y() + 1} and the interior spans
+ * {@code [popup.x() + 1, popup.x() + width - 1)}. For scrolled lists, {@code 
offset} is the index of the first visible
+ * entry, so on-screen row r maps to entry {@code offset + (r - firstRow)}.
+ */
+class ActionsPopupTest {
+
+    @Test
+    void resolvesClicksToEntriesWhenNotScrolled() {
+        Rect popup = new Rect(0, 0, 40, 12);
+        assertEquals(0, ActionsPopup.listItemAt(popup, 0, 20, 5, 1), "first 
row under the top border is entry 0");
+        assertEquals(3, ActionsPopup.listItemAt(popup, 0, 20, 5, 4), "the 
fourth interior row is entry 3");
+    }
+
+    @Test
+    void appliesScrollOffsetToClickedRow() {
+        // The list is scrolled so the first visible entry is index 7; the top 
interior row therefore maps to entry 7.
+        Rect popup = new Rect(0, 0, 40, 12);
+        assertEquals(7, ActionsPopup.listItemAt(popup, 7, 50, 5, 1), "offset 
shifts the first visible row to entry 7");
+        assertEquals(9, ActionsPopup.listItemAt(popup, 7, 50, 5, 3), "third 
visible row maps to entry 7 + 2");
+    }
+
+    @Test
+    void rejectsBorderAndOutsideClicks() {
+        Rect popup = new Rect(0, 0, 40, 12);
+        assertEquals(-1, ActionsPopup.listItemAt(popup, 0, 20, 5, 0), "the top 
border row is not an entry");
+        assertEquals(-1, ActionsPopup.listItemAt(popup, 0, 20, 0, 5), "the 
left border column is not an entry");
+        assertEquals(-1, ActionsPopup.listItemAt(popup, 0, 20, 39, 5), "the 
right border column is not an entry");
+        assertEquals(-1, ActionsPopup.listItemAt(popup, 0, 20, 5, 11), "the 
bottom border row is not an entry");
+        assertEquals(-1, ActionsPopup.listItemAt(popup, 0, 20, 60, 5), "a 
click outside the popup is not an entry");
+    }
+
+    @Test
+    void rejectsRowsPastTheLastEntry() {
+        // A popup with more interior rows than entries: a click below the 
last entry must not resolve to a phantom row.
+        Rect popup = new Rect(0, 0, 40, 12);
+        assertEquals(-1, ActionsPopup.listItemAt(popup, 0, 3, 5, 5), "row 4 
has no entry when only 3 entries exist");
+        // With an offset, an interior row that lands past the end is likewise 
rejected.
+        assertEquals(-1, ActionsPopup.listItemAt(popup, 48, 50, 5, 4), "offset 
48 + row 3 = entry 51 exceeds 50 entries");
+    }
+
+    @Test
+    void handlesNullGeometry() {
+        assertEquals(-1, ActionsPopup.listItemAt(null, 0, 20, 5, 5), "no 
captured geometry yet");
+    }
+}
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 7b154e5c86e1..515add2e67be 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
@@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test;
 import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class CamelMonitorTest {
@@ -153,4 +154,49 @@ class CamelMonitorTest {
         }
         throw new IllegalArgumentException("no hint pair for key " + key);
     }
+
+    // Footer key bindings are clickable only when the hint token maps to an 
unambiguous single key.
+    // footerKeyEvent parses the token; footerRegionAt maps a click x back to 
the region under it.
+
+    @Test
+    void footerKeyEventParsesFunctionKeys() {
+        assertTrue(CamelMonitor.footerKeyEvent("F1").isKey(KeyCode.F1), "F1 
maps to the F1 key");
+        assertTrue(CamelMonitor.footerKeyEvent(" F5 ").isKey(KeyCode.F5), 
"surrounding spaces are trimmed");
+        assertTrue(CamelMonitor.footerKeyEvent("F12").isKey(KeyCode.F12), "F12 
is the highest function key");
+    }
+
+    @Test
+    void footerKeyEventParsesNamedAndSingleCharKeys() {
+        assertTrue(CamelMonitor.footerKeyEvent("Enter").isKey(KeyCode.ENTER), 
"Enter maps to the Enter key");
+        assertTrue(CamelMonitor.footerKeyEvent("Esc").isKey(KeyCode.ESCAPE), 
"Esc maps to the Escape key");
+        assertTrue(CamelMonitor.footerKeyEvent("Tab").isKey(KeyCode.TAB), "Tab 
maps to the Tab key");
+        assertTrue(CamelMonitor.footerKeyEvent("d").isChar('d'), "a single 
letter maps to that character");
+        assertTrue(CamelMonitor.footerKeyEvent("?").isChar('?'), "'?' (help) 
maps to that character");
+        assertTrue(CamelMonitor.footerKeyEvent("1").isChar('1'), "a digit maps 
to that character");
+    }
+
+    @Test
+    void footerKeyEventRejectsAmbiguousAndInvalidTokens() {
+        assertNull(CamelMonitor.footerKeyEvent("Up/Down"), "a two-key hint is 
not clickable");
+        assertNull(CamelMonitor.footerKeyEvent("PgUp/PgDn"), "a paging hint is 
not clickable");
+        assertNull(CamelMonitor.footerKeyEvent("↑↓"), "arrow glyphs are not a 
single key");
+        assertNull(CamelMonitor.footerKeyEvent("F13"), "there is no F13 key");
+        assertNull(CamelMonitor.footerKeyEvent(""), "an empty token is not 
clickable");
+        assertNull(CamelMonitor.footerKeyEvent(null), "a null token is not 
clickable");
+    }
+
+    @Test
+    void footerRegionAtResolvesClicksToTheOwningRegion() {
+        // Two half-open regions [0, 5) and [10, 18) with a gap between them.
+        int[] startX = { 0, 10 };
+        int[] endX = { 5, 18 };
+        assertEquals(0, CamelMonitor.footerRegionAt(startX, endX, 0), "left 
edge of the first region");
+        assertEquals(0, CamelMonitor.footerRegionAt(startX, endX, 4), "last 
cell of the first region");
+        assertEquals(-1, CamelMonitor.footerRegionAt(startX, endX, 5), "column 
5 is past the first region (exclusive end)");
+        assertEquals(-1, CamelMonitor.footerRegionAt(startX, endX, 7), "column 
7 is in the gap between regions");
+        assertEquals(1, CamelMonitor.footerRegionAt(startX, endX, 10), "left 
edge of the second region");
+        assertEquals(1, CamelMonitor.footerRegionAt(startX, endX, 17), "last 
cell of the second region");
+        assertEquals(-1, CamelMonitor.footerRegionAt(startX, endX, 18), 
"column 18 is past the second region");
+        assertEquals(-1, CamelMonitor.footerRegionAt(null, endX, 3), "no 
captured geometry yet");
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java
index accedba0ce90..4ecad5467334 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTabRenderTest.java
@@ -28,9 +28,13 @@ import dev.tamboui.terminal.Frame;
 import dev.tamboui.text.Span;
 import dev.tamboui.tui.event.KeyEvent;
 import dev.tamboui.tui.event.KeyModifiers;
+import dev.tamboui.tui.event.MouseButton;
+import dev.tamboui.tui.event.MouseEvent;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -216,4 +220,70 @@ class OverviewTabRenderTest {
         assertTrue(foundRed, "Failed count should be rendered in LIGHT_RED");
     }
 
+    @Test
+    void clickingAnIntegrationRowSelectsItAndFiresPidChange() {
+        info.state = 5;
+        IntegrationInfo info2 = new IntegrationInfo();
+        info2.pid = "5678";
+        info2.name = "other-app";
+        info2.state = 5;
+
+        AtomicReference<List<IntegrationInfo>> data = new 
AtomicReference<>(List.of(info, info2));
+        AtomicReference<List<InfraInfo>> infraData = new 
AtomicReference<>(List.of());
+        MonitorContext ctx2 = new MonitorContext(data, infraData);
+        // The default sort is by name, so the rows are ordered other-app 
(5678), test-app (1234).
+        // Start with test-app selected so that clicking the first row 
(other-app) changes the selection.
+        ctx2.selectedPid = "1234";
+
+        boolean[] pidChanged = { false };
+        OverviewTab tab = new OverviewTab(ctx2, new MetricsCollector(), new 
HashSet<>(), () -> pidChanged[0] = true);
+
+        Rect area = new Rect(0, 0, 160, 30);
+        renderOnce(tab, area);
+
+        // The border and header put the first data row at tableArea.y() + 2.
+        Rect tableArea = tab.getTableArea();
+        int firstRowY = tableArea.y() + 2;
+        assertTrue(tab.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, 
tableArea.x() + 2, firstRowY), area),
+                "a click on an integration row is consumed");
+        assertEquals("5678", ctx2.selectedPid, "clicking the first row 
(other-app) selects that integration");
+        assertTrue(pidChanged[0], "selecting a different integration fires the 
pid-changed callback");
+    }
+
+    @Test
+    void clickingTheDividerRowSelectsNothing() {
+        info.state = 5;
+        InfraInfo infra = new InfraInfo();
+        infra.pid = "9999";
+        infra.alias = "kafka";
+        infra.alive = true;
+
+        AtomicReference<List<IntegrationInfo>> data = new 
AtomicReference<>(List.of(info));
+        AtomicReference<List<InfraInfo>> infraData = new 
AtomicReference<>(List.of(infra));
+        MonitorContext ctx2 = new MonitorContext(data, infraData);
+        ctx2.selectedPid = "1234";
+
+        boolean[] pidChanged = { false };
+        OverviewTab tab = new OverviewTab(ctx2, new MetricsCollector(), new 
HashSet<>(), () -> pidChanged[0] = true);
+
+        Rect area = new Rect(0, 0, 160, 30);
+        renderOnce(tab, area);
+
+        // Rows: 0 = the integration, 1 = the "Dev/Infra Services" divider, 2 
= the infra service.
+        Rect tableArea = tab.getTableArea();
+        int dividerRowY = tableArea.y() + 3;
+        assertTrue(tab.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, 
tableArea.x() + 2, dividerRowY), area),
+                "a click on the divider row is consumed");
+        assertEquals("1234", ctx2.selectedPid, "the divider row is not 
selectable, so the selection is unchanged");
+        assertFalse(pidChanged[0], "clicking the divider does not fire the 
pid-changed callback");
+    }
+
+    // ---- Helper methods ----
+
+    private void renderOnce(OverviewTab tab, Rect area) {
+        Buffer buffer = Buffer.empty(area);
+        Frame frame = Frame.forTesting(buffer);
+        tab.render(frame, area);
+    }
+
 }

Reply via email to