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);
+ }
+
}