You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/05/21 21:16:00 UTC
(camel) 04/11: camel-tui: Add caption overlay with typewriter effect
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-run-options-and-features
in repository https://gitbox.apache.org/repos/asf/camel.git
commit cd64582458fd2d20aaa40357a894fa9d3979bf97
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Thu May 21 22:09:16 2026 +0200
camel-tui: Add caption overlay with typewriter effect
Adds a caption feature for recordings and education that displays
text with a typewriter animation, holds, then fades out.
Triggered via Ctrl+T or F2 menu. Supports multi-line via \n.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 32 ++-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 21 ++
.../jbang/core/commands/tui/CaptionOverlay.java | 217 +++++++++++++++++++++
3 files changed, 263 insertions(+), 7 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 f5c4826f1b46..02d4bd1f2ee5 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
@@ -59,11 +59,12 @@ class ActionsPopup {
private static final int ACTION_RUN_EXAMPLE = 0;
private static final int ACTION_SHOW_DOCS = 1;
- private static final int ACTION_SCREENSHOT = 2;
- private static final int ACTION_SHOW_KEYSTROKES = 3;
- private static final int ACTION_DOCTOR = 4;
- private static final int ACTION_STOP_ALL = 5;
- private static final int ACTION_COUNT = 6;
+ private static final int ACTION_CAPTION = 2;
+ private static final int ACTION_SCREENSHOT = 3;
+ private static final int ACTION_SHOW_KEYSTROKES = 4;
+ private static final int ACTION_DOCTOR = 5;
+ private static final int ACTION_STOP_ALL = 6;
+ private static final int ACTION_COUNT = 7;
private final Supplier<Set<String>> runningNames;
private final Supplier<List<IntegrationInfo>> integrations;
@@ -94,6 +95,7 @@ class ActionsPopup {
private final DoctorPopup doctorPopup = new DoctorPopup();
private final StopAllPopup stopAllPopup;
+ private final CaptionOverlay captionOverlay;
private final List<PendingLaunch> pendingLaunches = new ArrayList<>();
private String launchNotification;
@@ -101,10 +103,11 @@ class ActionsPopup {
private long launchNotificationExpiry;
ActionsPopup(Supplier<Set<String>> runningNames, Supplier<List<IntegrationInfo>> integrations,
- Supplier<List<InfraInfo>> infraServices,
+ Supplier<List<InfraInfo>> infraServices, CaptionOverlay captionOverlay,
Runnable screenshotAction, Runnable toggleKeystrokes, Supplier<Boolean> keystrokesEnabled) {
this.runningNames = runningNames;
this.integrations = integrations;
+ this.captionOverlay = captionOverlay;
this.screenshotAction = screenshotAction;
this.toggleKeystrokes = toggleKeystrokes;
this.keystrokesEnabled = keystrokesEnabled;
@@ -117,7 +120,7 @@ class ActionsPopup {
boolean isVisible() {
return showActionsMenu || showExampleBrowser || showNameInput || showDocPicker || showDocViewer
- || doctorPopup.isVisible() || stopAllPopup.isVisible();
+ || doctorPopup.isVisible() || stopAllPopup.isVisible() || captionOverlay.isInputVisible();
}
void open() {
@@ -133,6 +136,7 @@ class ActionsPopup {
showDocViewer = false;
doctorPopup.close();
stopAllPopup.close();
+ captionOverlay.close();
}
String notification() {
@@ -219,6 +223,9 @@ class ActionsPopup {
}
return true;
}
+ if (captionOverlay.isInputVisible()) {
+ return captionOverlay.handleKeyEvent(ke);
+ }
if (stopAllPopup.handleKeyEvent(ke)) {
checkStopAllNotification();
return true;
@@ -253,6 +260,9 @@ class ActionsPopup {
showActionsMenu = false;
stopAllPopup.open();
checkStopAllNotification();
+ } else if (sel == ACTION_CAPTION) {
+ showActionsMenu = false;
+ captionOverlay.openInput();
}
}
}
@@ -283,9 +293,16 @@ class ActionsPopup {
if (stopAllPopup.isVisible()) {
stopAllPopup.render(frame, area);
}
+ if (captionOverlay.isInputVisible()) {
+ captionOverlay.render(frame, area);
+ }
}
void renderFooter(List<Span> spans) {
+ if (captionOverlay.isInputVisible()) {
+ captionOverlay.renderFooter(spans);
+ return;
+ }
if (stopAllPopup.isVisible()) {
stopAllPopup.renderFooter(spans);
return;
@@ -352,6 +369,7 @@ class ActionsPopup {
ListWidget list = ListWidget.builder()
.items(ListItem.from(" 🐪 Run an example..."),
ListItem.from(" 📖 Show Documentation"),
+ ListItem.from(" 💬 Caption... (Ctrl+T)"),
ListItem.from(" 📸 Take Screenshot"),
ListItem.from(keystrokeLabel),
ListItem.from(" 🩺 Run Doctor"),
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 42271e55bb5c..72a1265c6d39 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
@@ -202,6 +202,7 @@ public class CamelMonitor extends CamelCommand {
private volatile boolean pendingScreenshot;
private boolean recording;
private final List<KeyRecord> recentKeys = new ArrayList<>();
+ private final CaptionOverlay captionOverlay = new CaptionOverlay();
private final ActionsPopup actionsPopup = new ActionsPopup(
() -> data.get().stream()
@@ -214,6 +215,7 @@ public class CamelMonitor extends CamelCommand {
() -> infraData.get().stream()
.filter(i -> !i.vanishing)
.collect(Collectors.toList()),
+ captionOverlay,
() -> pendingScreenshot = true,
() -> recording = !recording,
() -> recording);
@@ -304,6 +306,14 @@ public class CamelMonitor extends CamelCommand {
recentKeys.add(new KeyRecord(label, System.currentTimeMillis()));
}
}
+ if (captionOverlay.isCaptionVisible()) {
+ captionOverlay.handleKeyEvent(ke);
+ return true;
+ }
+ if (ke.hasCtrl() && ke.isChar('t')) {
+ captionOverlay.openInput();
+ return true;
+ }
if (actionsPopup.isVisible()) {
return actionsPopup.handleKeyEvent(ke);
}
@@ -538,6 +548,7 @@ public class CamelMonitor extends CamelCommand {
if (event instanceof TickEvent) {
long now = System.currentTimeMillis();
actionsPopup.tick(now);
+ captionOverlay.tick(now);
if (recording && !recentKeys.isEmpty()) {
long cutoff = now - 2000;
recentKeys.removeIf(k -> k.timestamp() < cutoff);
@@ -747,6 +758,9 @@ public class CamelMonitor extends CamelCommand {
renderKillConfirm(frame, mainChunks.get(4));
}
actionsPopup.render(frame, mainChunks.get(4));
+ if (captionOverlay.isCaptionVisible()) {
+ captionOverlay.render(frame, mainChunks.get(4));
+ }
renderFooter(frame, mainChunks.get(5));
lastBuffer = frame.buffer();
@@ -1570,6 +1584,13 @@ public class CamelMonitor extends CamelCommand {
screenshotMessage = null;
List<Span> spans = new ArrayList<>();
+
+ if (captionOverlay.isCaptionVisible()) {
+ captionOverlay.renderFooter(spans);
+ frame.renderWidget(Paragraph.from(Line.from(spans)), area);
+ return;
+ }
+
MonitorTab tab = activeTab();
if (tab != null) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
new file mode 100644
index 000000000000..6574e4eb640d
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
@@ -0,0 +1,217 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+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.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.input.TextInput;
+import dev.tamboui.widgets.input.TextInputState;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint;
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast;
+
+class CaptionOverlay {
+
+ private static final long CHAR_DELAY_MS = 50;
+ private static final long HOLD_DURATION_MS = 3000;
+ private static final long FADE_DURATION_MS = 1000;
+
+ private boolean showInput;
+ private TextInputState inputState;
+
+ private String captionText;
+ private long captionStartTime;
+ private long captionFullyTypedTime;
+
+ boolean isInputVisible() {
+ return showInput;
+ }
+
+ boolean isCaptionVisible() {
+ return captionText != null;
+ }
+
+ boolean isVisible() {
+ return showInput || captionText != null;
+ }
+
+ void openInput() {
+ showInput = true;
+ inputState = new TextInputState("");
+ }
+
+ void close() {
+ showInput = false;
+ inputState = null;
+ captionText = null;
+ captionFullyTypedTime = 0;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (showInput) {
+ if (ke.isCancel()) {
+ showInput = false;
+ inputState = null;
+ } else if (ke.isConfirm()) {
+ String text = inputState.text().trim();
+ showInput = false;
+ inputState = null;
+ if (!text.isEmpty()) {
+ captionText = text;
+ captionStartTime = System.currentTimeMillis();
+ captionFullyTypedTime = 0;
+ }
+ } else if (ke.isDeleteBackward()) {
+ inputState.deleteBackward();
+ } else if (ke.isDeleteForward()) {
+ inputState.deleteForward();
+ } else if (ke.isLeft()) {
+ inputState.moveCursorLeft();
+ } else if (ke.isRight()) {
+ inputState.moveCursorRight();
+ } else if (ke.isHome()) {
+ inputState.moveCursorToStart();
+ } else if (ke.isEnd()) {
+ inputState.moveCursorToEnd();
+ } else if (ke.code() == KeyCode.CHAR) {
+ inputState.insert(ke.character());
+ }
+ return true;
+ }
+ if (captionText != null) {
+ captionText = null;
+ captionFullyTypedTime = 0;
+ return true;
+ }
+ return false;
+ }
+
+ void tick(long now) {
+ if (captionText == null) {
+ return;
+ }
+ int totalChars = captionText.length();
+ long elapsed = now - captionStartTime;
+ int charsToShow = (int) (elapsed / CHAR_DELAY_MS);
+
+ if (charsToShow >= totalChars && captionFullyTypedTime == 0) {
+ captionFullyTypedTime = now;
+ }
+ if (captionFullyTypedTime > 0 && now - captionFullyTypedTime > HOLD_DURATION_MS + FADE_DURATION_MS) {
+ captionText = null;
+ captionFullyTypedTime = 0;
+ }
+ }
+
+ void render(Frame frame, Rect area) {
+ if (showInput) {
+ renderInput(frame, area);
+ return;
+ }
+ if (captionText != null) {
+ renderCaption(frame, area);
+ }
+ }
+
+ void renderFooter(List<Span> spans) {
+ if (showInput) {
+ hint(spans, "Enter", "show");
+ hintLast(spans, "Esc", "cancel");
+ } else if (captionText != null) {
+ hintLast(spans, "any key", "dismiss");
+ }
+ }
+
+ private void renderInput(Frame frame, Rect area) {
+ int popupW = Math.min(50, area.width() - 4);
+ int popupH = 4;
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
+ Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" 💬 Caption ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE), Span.raw(" show │"),
+ Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" cancel "))))
+ .build();
+ frame.renderWidget(block, popup);
+
+ Rect inner = new Rect(popup.left() + 2, popup.top() + 1, popup.width() - 4, 1);
+ frame.renderWidget(Paragraph.from(Line.from(
+ Span.styled("Caption text (\\n for newline):", Style.EMPTY.dim()))), inner);
+
+ Rect inputArea = new Rect(popup.left() + 2, popup.top() + 2, popup.width() - 4, 1);
+ TextInput textInput = TextInput.builder()
+ .cursorStyle(Style.EMPTY.reversed())
+ .build();
+ frame.renderStatefulWidget(textInput, inputArea, inputState);
+ }
+
+ private void renderCaption(Frame frame, Rect area) {
+ long now = System.currentTimeMillis();
+ long elapsed = now - captionStartTime;
+ int charsToShow = Math.min((int) (elapsed / CHAR_DELAY_MS), captionText.length());
+ String visible = captionText.substring(0, charsToShow);
+
+ Style style;
+ if (captionFullyTypedTime == 0 || now - captionFullyTypedTime < HOLD_DURATION_MS) {
+ style = Style.EMPTY.fg(Color.WHITE).bold();
+ } else {
+ style = Style.EMPTY.dim();
+ }
+
+ String[] parts = visible.split("\\\\n", -1);
+ List<Line> lines = new ArrayList<>();
+ int maxWidth = 0;
+ for (String part : parts) {
+ lines.add(Line.from(Span.styled(" " + part + " ", style)));
+ maxWidth = Math.max(maxWidth, part.length() + 4);
+ }
+
+ int captionW = Math.min(maxWidth, area.width() - 2);
+ int captionH = lines.size();
+ int captionX = area.left() + Math.max(0, (area.width() - captionW) / 2);
+ int captionY = area.top() + Math.max(0, (area.height() - captionH) / 2);
+ Rect captionArea = new Rect(
+ captionX, captionY, Math.min(captionW, area.width()),
+ Math.min(captionH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, captionArea);
+ frame.renderWidget(Paragraph.builder()
+ .text(Text.from(lines.toArray(Line[]::new)))
+ .build(), captionArea);
+ }
+}