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