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 <[email protected]>
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 <[email protected]>
---
 .../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);
+    }
+}

Reply via email to