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 e1aed0bc7804 CAMEL-23869: Add mouse support to browser popups and fix
MemoryLeakTab crash
e1aed0bc7804 is described below
commit e1aed0bc780452846b3c24231fbbbc9f77a092fe
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Jul 5 17:48:13 2026 +0200
CAMEL-23869: Add mouse support to browser popups and fix MemoryLeakTab crash
Add scroll wheel and click-to-select mouse support to ExampleBrowserPopup
and InfraBrowserPopup. Track per-item line heights for accurate click
mapping
on multi-line wrapped descriptions. Fix modal popup mouse capture so tab bar
clicks don't leak through when F2 popup is open. Escape literal % signs in
MemoryLeakTab help text to prevent UnknownFormatConversionException.
CAMEL-23869: Add Switch Integration to F2 actions menu in camel-tui
Add Switch Integration (F3) entry to the F2 actions menu so the feature
is discoverable. The entry is dimmed when fewer than 2 integrations are
running, matching the F3 shortcut behavior.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 38 +++++++++++++--
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 10 ++--
.../core/commands/tui/ExampleBrowserPopup.java | 55 +++++++++++++++++++++
.../jbang/core/commands/tui/InfraBrowserPopup.java | 56 ++++++++++++++++++++++
.../dsl/jbang/core/commands/tui/MemoryLeakTab.java | 14 +++---
5 files changed, 159 insertions(+), 14 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 2e87ca17eccf..f1c64d419b77 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
@@ -51,6 +51,7 @@ class ActionsPopup {
enum Action {
GOTO_TAB,
+ SWITCH_INTEGRATION,
SEND_MESSAGE,
RUN_EXAMPLE,
RUN_FOLDER,
@@ -73,7 +74,7 @@ class ActionsPopup {
SHELL
}
- private static final int[] GROUP_SIZES = { 1, 6, 4, 5 };
+ private static final int[] GROUP_SIZES = { 2, 6, 4, 5 };
private static final int MCP_GROUP_SIZE = 4;
private static final int SHELL_GROUP_SIZE = 1;
@@ -89,6 +90,7 @@ class ActionsPopup {
private Runnable resetScreenAction;
private Runnable openShellAction;
private Runnable browseFilesAction;
+ private Runnable switchIntegrationAction;
private final Supplier<Boolean> tapeRecordingActive;
private MonitorContext ctx;
private boolean mcpEnabled;
@@ -181,6 +183,10 @@ class ActionsPopup {
this.browseFilesAction = browseFilesAction;
}
+ void setSwitchIntegrationAction(Runnable switchIntegrationAction) {
+ this.switchIntegrationAction = switchIntegrationAction;
+ }
+
void setGotoTabSupport(List<TabRegistry.TabEntry> entries, Runnable
callback) {
gotoTabPopup.setTabEntries(entries, callback);
}
@@ -248,6 +254,7 @@ class ActionsPopup {
private List<Action> buildVisualActionList() {
List<Action> flat = new ArrayList<>();
flat.add(Action.GOTO_TAB);
+ flat.add(Action.SWITCH_INTEGRATION);
flat.add(null);
flat.addAll(List.of(
Action.SEND_MESSAGE, Action.RUN_EXAMPLE, Action.RUN_FOLDER,
Action.RUN_INFRA, Action.BROWSE_FILES,
@@ -338,8 +345,9 @@ class ActionsPopup {
List<String> getActionLabels() {
List<String> labels = new ArrayList<>();
- // Group 0: Go to
+ // Group 0: Navigation
labels.add("Go to...");
+ labels.add("Switch Integration (F3)");
labels.add("───");
// Group 1: User Actions
labels.add("Send Message");
@@ -524,6 +532,13 @@ class ActionsPopup {
} else if (action == Action.GOTO_TAB) {
showActionsMenu = false;
gotoTabPopup.open();
+ } else if (action == Action.SWITCH_INTEGRATION) {
+ if (hasMultipleIntegrations()) {
+ showActionsMenu = false;
+ if (switchIntegrationAction != null) {
+ switchIntegrationAction.run();
+ }
+ }
} else if (action == Action.SHELL) {
showActionsMenu = false;
if (openShellAction != null) {
@@ -624,11 +639,17 @@ class ActionsPopup {
if (showActionsMenu) {
return handleListPopupMouse(me, actionsMenuRect, actionsMenuState,
visualActionCount(), this::isDividerIndex);
}
+ if (exampleBrowserPopup.isVisible()) {
+ return exampleBrowserPopup.handleMouseEvent(me);
+ }
+ if (infraBrowserPopup.isBrowserVisible()) {
+ return infraBrowserPopup.handleMouseEvent(me);
+ }
if (docViewerPopup.isPickerVisible() &&
docViewerPopup.getPickerIntegrations() != null) {
return handleListPopupMouse(me, docViewerPopup.getPickerRect(),
docViewerPopup.getPickerState(),
docViewerPopup.getPickerIntegrations().size(), i -> false);
}
- // Other sub-popups (forms, browsers, viewers) stay modal: consume the
event without acting on it.
+ // Other sub-popups (forms, viewers) stay modal: consume the event
without acting on it.
return true;
}
@@ -798,9 +819,13 @@ class ActionsPopup {
? TuiIcons.menuItem(TuiIcons.STOP, "Stop Tape Recording
(Ctrl+R)")
: TuiIcons.menuItem(TuiIcons.RECORD, "Start Tape Recording
(Ctrl+R)");
+ boolean canSwitch = hasMultipleIntegrations();
List<ListItem> items = new ArrayList<>();
- // Group 0: Go to
+ // Group 0: Navigation
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.GO_TO, "Go
to...")));
+ items.add(canSwitch
+ ? ListItem.from(TuiIcons.menuItem(TuiIcons.ARROW_BOTH, "Switch
Integration (F3)"))
+ : ListItem.from(TuiIcons.menuItem(TuiIcons.ARROW_BOTH, "Switch
Integration (F3)")).style(Style.EMPTY.dim()));
items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
// Group 1: User Actions
boolean hasSelection = ctx != null && ctx.selectedPid != null &&
!ctx.isInfraSelected();
@@ -854,6 +879,11 @@ class ActionsPopup {
frame.renderStatefulWidget(list, popup, actionsMenuState);
}
+ private boolean hasMultipleIntegrations() {
+ List<IntegrationInfo> ints = integrations.get();
+ return ints != null && ints.stream().filter(i -> !i.vanishing && i.pid
!= null).count() > 1;
+ }
+
void openDoc(IntegrationInfo info) {
showActionsMenu = false;
String error = docViewerPopup.openDoc(info);
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 65a5db83a6f4..2e37d0bb2374 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
@@ -252,6 +252,8 @@ public class CamelMonitor extends CamelCommand {
aiPanel.setContext(ctx);
actionsPopup.setOpenShellAction(shellPanel::open);
actionsPopup.setBrowseFilesAction(this::openFilesPopup);
+ actionsPopup.setSwitchIntegrationAction(
+ () -> popupManager.openSwitchPopup(ctx.selectedPid,
getNonVanishingIntegrations()));
tabRegistry = new TabRegistry(tabsState);
tabRegistry.initTabs(ctx, dataService, this::resetIntegrationTabState);
@@ -797,6 +799,11 @@ public class CamelMonitor extends CamelCommand {
return true;
}
+ // Modal popups capture all mouse events (including tab bar and footer)
+ if (actionsPopup.isVisible()) {
+ return actionsPopup.handleMouseEvent(me);
+ }
+
// Tab bar clicks: detect which tab was clicked and switch to it
if (me.isClick() && lastTabsArea != null && lastTabLabels != null) {
int tabsY = lastTabsArea.height() >= 2 ? lastTabsArea.y() + 1 :
lastTabsArea.y();
@@ -825,9 +832,6 @@ public class CamelMonitor extends CamelCommand {
if (popupManager.isMorePopupVisible() ||
popupManager.isSwitchPopupVisible()) {
return popupManager.handleMouseEvent(me,
tabRegistry.selectedTabIndex(), TAB_LOG);
}
- if (actionsPopup.isVisible()) {
- return actionsPopup.handleMouseEvent(me);
- }
if (filesBrowser.isVisible()) {
return false;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java
index dcbae7e6cdf2..9baded611241 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ExampleBrowserPopup.java
@@ -30,6 +30,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;
@@ -46,6 +48,8 @@ class ExampleBrowserPopup {
private boolean visible;
private final ListState listState = new ListState();
+ private Rect popupRect;
+ private int[] itemHeights;
private List<JsonObject> catalog;
private JsonObject selectedExample;
@@ -132,6 +136,49 @@ class ExampleBrowserPopup {
return true;
}
+ boolean handleMouseEvent(MouseEvent me) {
+ if (me.kind() == MouseEventKind.SCROLL_UP) {
+ navigate(-1);
+ return true;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+ navigate(1);
+ return true;
+ }
+ if (me.isClick()) {
+ if (popupRect != null && popupRect.contains(me.x(), me.y())) {
+ int idx = itemAtMouseY(me.y());
+ if (idx >= 0 && !isSeparatorIndex(idx)) {
+ listState.select(idx);
+ }
+ return true;
+ }
+ close();
+ return true;
+ }
+ return true;
+ }
+
+ private int itemAtMouseY(int mouseY) {
+ if (popupRect == null || itemHeights == null) {
+ return -1;
+ }
+ int firstRow = popupRect.top() + 1;
+ int relY = mouseY - firstRow;
+ if (relY < 0) {
+ return -1;
+ }
+ int offset = listState.offset();
+ int rowAcc = 0;
+ for (int i = offset; i < itemHeights.length; i++) {
+ rowAcc += itemHeights[i];
+ if (relY < rowAcc) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
void render(Frame frame, Rect area) {
if (catalog == null || catalog.isEmpty()) {
return;
@@ -141,6 +188,7 @@ class ExampleBrowserPopup {
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.popupRect = popup;
frame.renderWidget(Clear.INSTANCE, popup);
@@ -389,6 +437,7 @@ class ExampleBrowserPopup {
private List<ListItem> buildListItems(int width) {
List<ListItem> items = new ArrayList<>();
+ List<Integer> heights = new ArrayList<>();
String currentLevel = null;
for (JsonObject ex : catalog) {
String level = ex.getStringOrDefault("level", "beginner");
@@ -396,6 +445,7 @@ class ExampleBrowserPopup {
currentLevel = level;
String header = "── " + TuiHelper.capitalize(level) + " ──";
items.add(ListItem.from(header).style(Style.EMPTY.dim()));
+ heights.add(1);
}
String name = ex.getStringOrDefault("name", "");
String desc = ex.getStringOrDefault("description", "");
@@ -414,6 +464,7 @@ class ExampleBrowserPopup {
Style style = bundled ? Style.EMPTY : Style.EMPTY.dim();
if (desc.length() <= descCol) {
items.add(ListItem.from(prefix + desc).style(style));
+ heights.add(1);
} else {
String indent = " ".repeat(prefix.length());
List<Line> lines = new ArrayList<>();
@@ -423,13 +474,17 @@ class ExampleBrowserPopup {
lines.add(Line.from(indent + wrapped.get(w)));
}
items.add(ListItem.from(Text.from(lines.toArray(Line[]::new))).style(style));
+ heights.add(wrapped.size());
}
}
items.add(ListItem.from(""));
+ heights.add(1);
items.add(ListItem.from(" " + TuiIcons.BUNDLED + " = bundled " +
TuiIcons.ONLINE + " = online "
+ TuiIcons.DOCKER + " = Docker " +
TuiIcons.INFRA + " = infra services " + TuiIcons.CITRUS
+ " = Citrus tests")
.style(Style.EMPTY.dim()));
+ heights.add(1);
+ this.itemHeights =
heights.stream().mapToInt(Integer::intValue).toArray();
return items;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
index 4ac21e8b4d85..b52eb115f61e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
@@ -37,6 +37,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;
@@ -63,6 +65,8 @@ class InfraBrowserPopup {
private boolean showBrowser;
private boolean showPortDialog;
private final ListState browserState = new ListState();
+ private Rect popupRect;
+ private int[] itemHeights;
private List<InfraServiceEntry> catalog;
private InfraServiceEntry selectedService;
private int implIndex;
@@ -132,6 +136,52 @@ class InfraBrowserPopup {
return false;
}
+ boolean handleMouseEvent(MouseEvent me) {
+ if (!showBrowser) {
+ return true;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_UP) {
+ navigate(-1);
+ return true;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+ navigate(1);
+ return true;
+ }
+ if (me.isClick()) {
+ if (popupRect != null && popupRect.contains(me.x(), me.y())) {
+ int idx = itemAtMouseY(me.y());
+ if (idx >= 0 && catalog != null && idx < catalog.size() &&
!catalog.get(idx).running()) {
+ browserState.select(idx);
+ }
+ return true;
+ }
+ close();
+ return true;
+ }
+ return true;
+ }
+
+ private int itemAtMouseY(int mouseY) {
+ if (popupRect == null || itemHeights == null) {
+ return -1;
+ }
+ int firstRow = popupRect.top() + 1;
+ int relY = mouseY - firstRow;
+ if (relY < 0) {
+ return -1;
+ }
+ int offset = browserState.offset();
+ int rowAcc = 0;
+ for (int i = offset; i < itemHeights.length; i++) {
+ rowAcc += itemHeights[i];
+ if (relY < rowAcc) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
void render(Frame frame, Rect area) {
if (showBrowser) {
renderBrowser(frame, area);
@@ -281,16 +331,19 @@ class InfraBrowserPopup {
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.popupRect = popup;
frame.renderWidget(Clear.INSTANCE, popup);
int nameCol = 22;
List<ListItem> items = new ArrayList<>();
+ List<Integer> heights = new ArrayList<>();
for (InfraServiceEntry entry : catalog) {
String padded = String.format("%-" + nameCol + "s",
TuiHelper.truncate(entry.alias(), nameCol));
String prefix = TuiIcons.indent(TuiIcons.INFRA) + padded + " ";
if (entry.running()) {
items.add(ListItem.from(prefix +
"(running)").style(Style.EMPTY.dim()));
+ heights.add(1);
} else {
String implStr = entry.implementations().isEmpty() ? "" :
String.join(", ", entry.implementations());
String desc = entry.description();
@@ -300,6 +353,7 @@ class InfraBrowserPopup {
int descW = Math.max(10, popupW - prefix.length() - 2);
if (desc.length() <= descW) {
items.add(ListItem.from(prefix + desc));
+ heights.add(1);
} else {
String indent = " ".repeat(prefix.length());
List<Line> lines = new ArrayList<>();
@@ -309,9 +363,11 @@ class InfraBrowserPopup {
lines.add(Line.from(indent + wrapped.get(w)));
}
items.add(ListItem.from(Text.from(lines.toArray(Line[]::new))));
+ heights.add(wrapped.size());
}
}
}
+ this.itemHeights =
heights.stream().mapToInt(Integer::intValue).toArray();
long available = catalog.stream().filter(e -> !e.running()).count();
ListWidget list = ListWidget.builder()
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
index f2d6be86c8f4..de08309ea008 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MemoryLeakTab.java
@@ -923,17 +923,17 @@ class MemoryLeakTab extends AbstractTab {
After both complete, a comparison table shows how each class
behaved across the two runs. The **GROWTH** column shows the
- normalized growth as a percentage. For example, +30% means
- the class grew 30% faster than expected from the duration
+ normalized growth as a percentage. For example, +30%% means
+ the class grew 30%% faster than expected from the duration
increase alone. Entries under 1KB in both runs are filtered
out as noise.
### Trend Indicators
- - **↑ leak!** (red) — Growth >= +20%, very likely leak
- - **↑ leak?** (yellow) — Growth +10% to +20%, suspicious
- - **→ stable** (green) — Growth -20% to +10%, normal
- - **↓** (dim) — Growth < -20%, shrinking
+ - **↑ leak!** (red) — Growth >= +20%%, very likely leak
+ - **↑ leak?** (yellow) — Growth +10%% to +20%%, suspicious
+ - **→ stable** (green) — Growth -20%% to +10%%, normal
+ - **↓** (dim) — Growth < -20%%, shrinking
- **new** (yellow) — Only appeared in Run 2
- **gone** (dim) — Only appeared in Run 1
@@ -942,7 +942,7 @@ class MemoryLeakTab extends AbstractTab {
A **%s** warning appears when sample counts are too low
(fewer than 5 in either run) or diverge significantly from
the expected duration ratio. The growth percentage is shown
- with a **~** prefix (e.g. ~+53%) to indicate the value may
+ with a **~** prefix (e.g. ~+53%%) to indicate the value may
not be reliable. JFR sampling is statistical — low sample
counts produce noisy results. Re-run with a longer duration
to collect more samples.