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 fe01639b8396 chore: camel-jbang - camel tui --record plays the tape
itself, with commands for captions, highlights and tabs (#27663)
fe01639b8396 is described below
commit fe01639b8396082354ba915ccf1c8905889cb21c
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 10 16:08:46 2026 +0200
chore: camel-jbang - camel tui --record plays the tape itself, with
commands for captions, highlights and tabs (#27663)
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/images/jbang/camel-tui-tape-player.gif | Bin 0 -> 305206 bytes
.../ROOT/pages/camel-jbang-tui-ai-agents.adoc | 4 +-
.../ROOT/pages/camel-jbang-tui-settings.adoc | 162 ++++++-
.../modules/ROOT/pages/camel-jbang-tui.adoc | 2 +-
.../jbang-commands/camel-jbang-tui-monitor.adoc | 2 +-
.../ROOT/pages/jbang-commands/camel-jbang-tui.adoc | 2 +-
.../camel-jbang-plugin-commands-metadata.json | 2 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 56 ++-
.../dsl/jbang/core/commands/tui/TapePlayer.java | 480 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/TuiCommand.java | 2 +-
.../tui/CamelMonitorRecordOptionsTest.java | 38 +-
.../jbang/core/commands/tui/TapePlayerTest.java | 256 +++++++++++
12 files changed, 983 insertions(+), 23 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif
new file mode 100644
index 000000000000..ce90576a81b3
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index fd312ae018d1..8e95e6e420f7 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -182,7 +182,9 @@ by purpose:
* *Edit* -- write a source file in the integration's source directory
(`camel_write_file`), see below
* *Annotate* -- locate text and diagram nodes by coordinates, draw shapes
(boxes, highlights,
arrows, underlines, text labels), show captions with typewriter animation
-* *Present* -- take screenshots, record tape sessions, control demo pacing
+* *Present* -- take screenshots, record tape sessions, control demo pacing. To
record a demo as a GIF,
+ an agent can also write a `.tape` file using the same commands and play it
with `camel tui --record`, see
+ xref:camel-jbang-tui-settings.adoc#_scripted_recording[Scripted Recording]
== Editing source files from an AI agent
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc
index 526f99653d19..d97bfa93f559 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc
@@ -284,27 +284,38 @@ same as if you'd run `camel tui` again in a new terminal.
== Recording Demos
-The TUI can record terminal sessions for demos and documentation.
+The TUI can record its sessions for demos and documentation. A session is
scripted as a `.tape`
+file, in the format of https://github.com/charmbracelet/vhs[VHS], which the
TUI plays and records
+to an https://asciinema.org/[Asciinema] `.cast` file. The `.cast` file then
converts to an
+animated GIF.
=== Live Recording
Use the *F2* actions menu and select *Start Tape Recording* to start recording
your session
as a `.tape` file. Select *Stop Tape Recording* to stop. The tape captures
your keystrokes
-with timing, producing a script that can be replayed or converted to an
animated GIF.
+with timing, producing a script that can be played back with `--record` (see
below), edited,
+or converted to an animated GIF.
=== Scripted Recording
-Use the `--record` flag to replay a `.tape` file in headless mode and produce
an
-https://asciinema.org/[Asciinema] `.cast` recording:
+Use the `--record` flag to play a `.tape` file in the TUI and record what
happens on screen:
[source,bash]
----
-camel tui --record=demo.tape
+camel tui orders --record=demo.tape
----
The `.cast` file is written next to the tape, with the `.tape` suffix replaced
by `.cast`.
Recording is headless: the TUI is driven entirely by the tape rather than by
your terminal,
-so no keyboard input is read and nothing is drawn on screen.
+so no keyboard input is read and nothing is drawn on screen. The integrations
are the ones
+running on your machine, as with a plain `camel tui`, so start them before
recording, and
+name the integration to select it from the start (`orders` above).
+
+The tape is played by the TUI itself: the keys go to the TUI as if typed, and
on top of the
+VHS commands there are commands for the TUI, such as showing a caption,
highlighting text on the
+screen, or switching tab by name. When the tape ends, the TUI quits and the
recording is saved.
+Problems in the tape, such as an unknown command or a `Wait` that timed out,
are printed as
+`Tape: ...` warnings, and the rest of the tape still plays.
The recorded terminal is 200x50 by default, which is wider than a
documentation page can
display. Use `--record-size` to record at a size that fits, and `--record-fps`
or
@@ -312,20 +323,149 @@ display. Use `--record-size` to record at a size that
fits, and `--record-fps` o
[source,bash]
----
-camel tui --record=demo.tape --record-size=160x44 --record-fps=15
+camel tui orders --record=demo.tape --record-size=120x32
--record-duration=60000
----
+`--record-duration` is in milliseconds and stops the recording even when the
tape is still
+playing, so give a long tape enough time.
+
`--record` cannot be combined with `--web`. Recording drives a headless TUI
from the tape, and
the recording configuration applies to the whole process, so every browser
session would be
recorded into the same `.cast` file. Camel rejects the combination with an
error instead.
+==== Tape commands
+
+A tape has one command per line. Lines starting with `#` are comments.
Durations are written
+as `500ms`, `2s` or `1.5s`; a bare number is seconds. Text is in double
quotes, where `\n` is
+a new line and `\"` a quote. Most commands take a timing as `Command@duration`.
+
+[cols="2,5",options="header"]
+|===
+| Command | Description
+
+| `Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Insert`, `Space`, `Up`,
`Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `F1` .. `F12`
+| Presses the key. A count repeats it (`Down 3`), and `@` sets the gap between
the presses
+(`Down@300ms 3`, default 50ms).
+
+| `Ctrl+<key>`, `Alt+<key>`, `Shift+<key>`
+| Presses the key with the modifiers, which can be combined (`Ctrl+Shift+Up`).
The key is a
+key name or a single character: `Ctrl+t`, `Shift+F8`, `Shift+Tab`.
+
+| `Type "text"`
+| Types the text, one character at a time, 50ms apart. `Type@100ms "text"`
types slower.
+A `\n` in the text presses *Enter*, and `\t` presses *Tab*. A single letter
+key such as `q` is typed with `Type "q"`.
+
+| `Sleep <duration>`
+| Pauses the tape, so the viewer can read the screen.
+
+| `Wait /regex/`
+| Waits until a line on the screen matches the regular expression, up to 15
seconds
+(`Wait@30s /orders/` waits longer). `Wait+Screen /regex/` matches the whole
screen, with the
+lines separated by new lines, instead of each line. Use a `Wait` after a key
that starts something
+slow, rather than guessing a `Sleep`.
+
+| `Screenshot`
+| Saves a screenshot of the screen, as the *F2* actions menu does.
+
+| `Source other.tape`
+| Plays the commands of another tape, relative to this one. Useful for a
common opening.
+
+| `Caption "text"`
+| Shows a caption on the screen with a typewriter animation, which stays until
a key is pressed.
+`Caption@4s "text"` closes it after 4 seconds, and then keys go to the TUI
while it shows.
+Use `\n` to break lines.
+
+| `Highlight "text"`
+| Highlights the first place on the screen showing the text, such as a key in
the footer or a
+node in the diagram. `Highlight@3s "text"` removes it after 3 seconds. Several
highlights can be on
+the screen at the same time.
+
+| `ClearDrawing`
+| Removes the highlights.
+
+| `Tab "name"`
+| Switches to the tab, such as `Tab "Log"` or `Tab "Diagram"`, without
pressing its number key.
+`Tab` alone, or with a count such as `Tab 2`, presses the *Tab* key.
+
+| `Integration "name"`
+| Selects the integration by its name or PID.
+
+| `Action "name"`
+| Runs an entry of the *F2* actions menu by its name, such as
`show-keystrokes`, `reset-stats`
+or `next-theme`, or by its menu label.
+
+| `Theme "name"`
+| Switches the theme, such as `Theme "dracula"`.
+|===
+
+The `Set`, `Output` and `Require` commands of VHS are skipped: the recording
takes its size and
+file from the `--record` options. `Hide` and `Show` are not supported, as
everything the TUI
+shows is recorded.
+
+==== Writing a tape
+
+The tape below opens the Diagram tab of the `orders` integration, explains the
screen with
+captions and a highlight, and asks the AI panel a question. Every step that
depends on the TUI
+being ready waits for it on the screen first, so the tape plays the same on a
slow machine:
+
+[source,text]
+----
+# wait for the integration to be discovered
+Wait@30s /orders/
+Caption@3s "The Camel TUI tape player"
+Sleep 3s
+
+Tab "Diagram"
+Wait /Topology/
+Sleep 1s
+Highlight@3s "orders"
+Caption@3s "Highlight finds text on the screen"
+Sleep 3s
+
+# the AI panel
+F8
+Wait /Ask a question/
+Type@80ms "hello"
+Sleep 1500ms
+F8
+Sleep 1s
+----
+
+Recorded with `camel tui orders --record=demo.tape --record-size=120x32` and
converted with `agg`, it plays as:
+
+image::jbang/camel-tui-tape-player.gif[The example tape played: captions, a
highlight on the Diagram tab, and a question typed in the AI panel]
+
+A few tips for a tape that reads well:
+
+* Start with a `Wait` for something the first screen shows, as the TUI needs a
moment to find the
+ running integrations.
+* After each `Caption@<n>s`, `Sleep` for as long, or the next keys play while
the caption is still
+ being typed.
+* Pair a `Highlight` with a `Caption` that says what the viewer looks at, and
highlight a key
+ in the footer before pressing it, such as `Highlight@2s "Shift+F8"` followed
by `Shift+F8`.
+* Use `Action "show-keystrokes"` at the start to show each key press at the
bottom of the screen.
+* Record at the size the page shows, such as `--record-size=120x32`, so text
is not scaled down.
+
+TIP: An AI agent can write and run the tape itself: the commands for the TUI
are the same as the
+xref:camel-jbang-tui-ai-agents.adoc[MCP tools] of the TUI (`tui_show_caption`,
`tui_locate` with
+`tui_draw_shape`, `tui_navigate`, `tui_action` and `tui_set_theme`), and
`tui_get_screen` tells what
+text to `Wait` for. Run the tape, read the `Tape:` warnings, fix the tape, and
run it again.
+
=== Converting to GIF
-Convert recordings using https://github.com/asciinema/agg[agg] (for `.cast`
files) or
-https://github.com/charmbracelet/vhs[VHS] (for `.tape` files):
+Convert the `.cast` recording with https://github.com/asciinema/agg[agg]:
[source,bash]
----
-agg recording.cast out.gif # .cast -> .gif
-vhs demo.tape # .tape -> .gif
+agg demo.cast demo.gif
+agg --speed 1.5 --font-size 14 --last-frame-duration 3 demo.cast demo.gif
----
+
+The `--speed` option plays the recording faster, and `--idle-time-limit` caps
every pause at that many
+seconds (5 by default), so raise it when a pause is there for the viewer to
read a caption.
+
+A tape that only uses VHS commands can also be turned into a GIF with
+https://github.com/charmbracelet/vhs[VHS] directly (`vhs demo.tape`), which
runs the TUI in a
+terminal of its own. The commands for the TUI, such as `Caption`, `Highlight`
and `Tab`, work
+with `--record` only.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index a5ee7fda7e58..cc9122bcc532 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -182,7 +182,7 @@ See the
xref:camel-jbang-tui-source-editor.adoc#_keyboard_shortcuts[keyboard sho
|
| `--record`
-| Replay a `.tape` file and record the session to an Asciinema `.cast` file.
+| Play a `.tape` file in the TUI and record it to an Asciinema `.cast` file.
See xref:camel-jbang-tui-settings.adoc#_scripted_recording[Scripted Recording]
for the tape commands.
|
| `--record-size`
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
index 36fc29d9220e..a2942f5a0dab 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
@@ -23,7 +23,7 @@ camel tui monitor [options]
| Option | Description | Default | Type
| `--mcp` | Enable embedded MCP server for AI agent access to the TUI | |
boolean
| `--mcp-port` | MCP server port | 8123 | int
-| `--record` | Replay a .tape file inside the TUI and record to an Asciinema
.cast file | | String
+| `--record` | Play a .tape file in the TUI and record it to an Asciinema
.cast file | | String
| `--record-duration` | Maximum duration in milliseconds captured by --record
| 120000 | int
| `--record-fps` | Frames per second captured by --record | 10 | int
| `--record-size` | Size of the recorded terminal for --record, as
<cols>x<rows> | 200x50 | String
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc
index b21f60019690..f716223e2286 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc
@@ -32,7 +32,7 @@ camel tui [options]
| Option | Description | Default | Type
| `--mcp` | Enable embedded MCP server for AI agent access to the TUI | |
boolean
| `--mcp-port` | MCP server port | 8123 | int
-| `--record` | Replay a .tape file inside the TUI and record to an Asciinema
.cast file | | String
+| `--record` | Play a .tape file in the TUI and record it to an Asciinema
.cast file | | String
| `--record-duration` | Maximum duration in milliseconds captured by --record
| 120000 | int
| `--record-fps` | Frames per second captured by --record | 10 | int
| `--record-size` | Size of the recorded terminal for --record, as
<cols>x<rows> | 200x50 | String
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
index 280ee0d05414..265a9523e427 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
@@ -1,5 +1,5 @@
{
"commands": [
- { "name": "tui", "fullName": "tui", "description": "Camel TUI",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.tui.TuiCommand",
"plugin": "tui", "options": [ { "names": "--mcp", "description": "Enable
embedded MCP server for AI agent access to the TUI", "javaType": "boolean",
"type": "boolean" }, { "names": "--mcp-port", "description": "MCP server port",
"defaultValue": "8123", "javaType": "int", "type": "integer" }, { "names":
"--record", "description": "Replay a .tape fil [...]
+ { "name": "tui", "fullName": "tui", "description": "Camel TUI",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.tui.TuiCommand",
"plugin": "tui", "options": [ { "names": "--mcp", "description": "Enable
embedded MCP server for AI agent access to the TUI", "javaType": "boolean",
"type": "boolean" }, { "names": "--mcp-port", "description": "MCP server port",
"defaultValue": "8123", "javaType": "int", "type": "integer" }, { "names":
"--record", "description": "Play a .tape file [...]
]
}
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 19d7b8f82d71..79c02ec1ffdc 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
@@ -29,6 +29,7 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;
@@ -97,7 +98,6 @@ public class CamelMonitor extends CamelCommand {
*/
static final List<String> RECORD_PROPERTIES = List.of(
"tamboui.record",
- "tamboui.record.config",
"tamboui.record.width",
"tamboui.record.height",
"tamboui.record.duration",
@@ -118,7 +118,7 @@ public class CamelMonitor extends CamelCommand {
long refreshInterval = DEFAULT_REFRESH_MS;
@CommandLine.Option(names = { "--record" },
- description = "Replay a .tape file inside the TUI and
record to an Asciinema .cast file",
+ description = "Play a .tape file in the TUI and record
it to an Asciinema .cast file",
arity = "0..1")
String record;
@@ -270,7 +270,8 @@ public class CamelMonitor extends CamelCommand {
/**
* Hands the {@code --record*} options to TamboUI through the {@link
#RECORD_PROPERTIES} system properties, which is
- * the only way TamboUI accepts a recording configuration.
+ * the only way TamboUI accepts a recording configuration. TamboUI only
records: the tape is played by
+ * {@link TapePlayer} (see {@link #startTapePlayer}), which knows the keys
and commands of the TUI.
*/
void configureRecording() {
if (record == null) {
@@ -288,14 +289,58 @@ public class CamelMonitor extends CamelCommand {
Path tapeFile = Path.of(record);
Path castFile = Path.of(record.replaceAll("\\.tape$", "") + ".cast");
int[] size = parseRecordSize(recordSize);
+ if (!Files.isRegularFile(tapeFile)) {
+ throw new CommandLine.ParameterException(
+ new CommandLine(this), "Tape file not found: " +
tapeFile.toAbsolutePath());
+ }
System.setProperty("tamboui.record",
castFile.toAbsolutePath().toString());
- System.setProperty("tamboui.record.config",
tapeFile.toAbsolutePath().toString());
System.setProperty("tamboui.record.width", String.valueOf(size[0]));
System.setProperty("tamboui.record.height", String.valueOf(size[1]));
System.setProperty("tamboui.record.duration",
String.valueOf(recordDuration));
System.setProperty("tamboui.record.fps", String.valueOf(recordFps));
}
+ /**
+ * Plays the {@code --record} tape on a background thread: its keys go
through the same queue as the keys of an MCP
+ * client, its TUI commands call the MCP tools, and it quits the TUI at
the end of the tape, which ends the
+ * recording.
+ */
+ private void startTapePlayer(TuiRunner tui) throws IOException {
+ TapePlayer player = new TapePlayer(warning ->
System.err.println("Tape: " + warning));
+ List<TapePlayer.Step> steps = player.parse(Path.of(record));
+ TuiToolRegistry tools = new TuiToolRegistry(mcpFacade);
+ TapePlayer.Driver driver = new TapePlayer.Driver() {
+ @Override
+ public void key(KeyEvent event) {
+ pendingKeys.add(new McpFacade.PendingKey(event,
System.currentTimeMillis()));
+ }
+
+ @Override
+ public String screen() {
+ return TapePlayer.screenText(recordingManager.getLastBuffer());
+ }
+
+ @Override
+ public String tool(String name, Map<String, Object> args) throws
Exception {
+ return tools.execute(name, args);
+ }
+
+ @Override
+ public void quit() {
+ tui.quit();
+ }
+ };
+ Thread thread = new Thread(() -> {
+ try {
+ player.play(steps, driver);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "tui-tape-player");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
/**
* Undoes {@link #configureRecording()} at the end of the session that ran
it.
* <p>
@@ -1148,6 +1193,9 @@ public class CamelMonitor extends CamelCommand {
Signal.handle(new Signal("INT"), sig -> tui.quit());
}
updateWindowTitle();
+ if (record != null) {
+ startTapePlayer(tui);
+ }
tui.run(
this::handleEvent,
this::render);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java
new file mode 100644
index 000000000000..7612aa8c8014
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java
@@ -0,0 +1,480 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Plays a tape for {@code camel tui --record}: the TUI is driven by the tape
while TamboUI records it to an asciinema
+ * {@code .cast} file.
+ * <p>
+ * A tape uses the <a href="https://github.com/charmbracelet/vhs">VHS</a>
syntax (keys, {@code Type}, {@code Sleep},
+ * {@code Wait /regex/}) plus commands for the TUI itself ({@code Caption},
{@code Highlight}, {@code Tab}, ...). The
+ * keys go through the same queue as the keys an MCP client sends, so every
key works, function keys and modifiers
+ * included, and the TUI commands call the same tools an MCP client calls.
+ */
+final class TapePlayer {
+
+ /** What the player drives: the TUI, or a test double. */
+ interface Driver {
+ /** Presses a key. */
+ void key(KeyEvent event);
+
+ /** The screen as text, its rows separated by newlines; null before
the first frame is rendered. */
+ String screen();
+
+ /** Calls a TUI tool (as an MCP client does) and returns its result. */
+ String tool(String name, Map<String, Object> args) throws Exception;
+
+ /** Ends the TUI, which ends the recording. */
+ void quit();
+ }
+
+ /** One step of a tape. */
+ sealed interface Step
+ permits KeyStep, SleepStep, WaitStep, ToolStep, HighlightStep {
+ }
+
+ record KeyStep(KeyEvent event) implements Step {
+ }
+
+ record SleepStep(long millis) implements Step {
+ }
+
+ /**
+ * Waits until a line of the screen (or the whole screen, its rows joined
by newlines) matches the pattern.
+ */
+ record WaitStep(Pattern pattern, boolean wholeScreen, long timeoutMillis,
String line) implements Step {
+ }
+
+ record ToolStep(String name, Map<String, Object> args, String line)
implements Step {
+ }
+
+ /** Marks the first place on the screen showing the text, optionally for a
number of seconds. */
+ record HighlightStep(String text, int seconds, String line) implements
Step {
+ }
+
+ /** How long typing waits between characters, and keys between presses,
unless the tape says otherwise. */
+ static final long DEFAULT_TYPING_MILLIS = 50;
+ /** How long a Wait waits for its text when the tape gives no timeout, as
in VHS. */
+ static final long DEFAULT_WAIT_MILLIS = 15000;
+ /** How often the screen is checked while waiting. */
+ static final long WAIT_POLL_MILLIS = 100;
+ /** How long the first frame may take to render before the tape starts
anyway. */
+ static final long FIRST_FRAME_MILLIS = 10000;
+ /** How long the last frame stays before the TUI quits, so the recording
shows it. */
+ static final long END_PAUSE_MILLIS = 1000;
+
+ private final Consumer<String> warnings;
+
+ TapePlayer(Consumer<String> warnings) {
+ this.warnings = warnings;
+ }
+
+ // ---- Parsing ----
+
+ /** Parses a tape file; {@code Source} includes other tapes relative to
it. */
+ List<Step> parse(Path tape) throws IOException {
+ List<Step> steps = new ArrayList<>();
+ Path file = tape.toAbsolutePath().normalize();
+ Deque<Path> including = new ArrayDeque<>();
+ including.push(file);
+ parse(Files.readAllLines(file), file.getParent(), including, steps);
+ return steps;
+ }
+
+ /** Parses the lines of a tape into steps; unknown lines are reported and
skipped. */
+ List<Step> parse(List<String> lines) {
+ List<Step> steps = new ArrayList<>();
+ parse(lines, null, new ArrayDeque<>(), steps);
+ return steps;
+ }
+
+ /** The tapes being parsed are in {@code including}, so a tape that
sources itself (in a loop) is reported. */
+ private void parse(List<String> lines, Path dir, Deque<Path> including,
List<Step> steps) {
+ for (String raw : lines) {
+ String line = raw.strip();
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+ try {
+ parseLine(line, dir, including, steps);
+ } catch (IllegalArgumentException e) {
+ warnings.accept(e.getMessage() + ": " + line);
+ }
+ }
+ }
+
+ private void parseLine(String line, Path dir, Deque<Path> including,
List<Step> steps) {
+ // The command is the first word, optionally with a timing:
Command@duration ("Down@200ms 3", "Type@100ms
+ // "text"", "Wait@10s /Ready/", "Caption@5s "text""). Only the first
word is checked for '@', so text such as
+ // Type "[email protected]" is typed as it is.
+ String[] parts = line.split("\\s+", 2);
+ String head = parts[0];
+ String args = parts.length > 1 ? parts[1].strip() : "";
+ long timing = -1;
+ int at = head.indexOf('@');
+ if (at > 0) {
+ timing = parseDuration(head.substring(at + 1));
+ head = head.substring(0, at);
+ }
+ String command = head.toLowerCase(Locale.ROOT);
+ // Tab alone or with a count (Tab 2) is the key, as in VHS; Tab "name"
switches to the tab
+ if ("tab".equals(command) && (args.isEmpty() ||
args.chars().allMatch(Character::isDigit))) {
+ command = "";
+ }
+
+ switch (command) {
+ // directives for the vhs tool (terminal settings, output file,
required programs): the recording takes
+ // its size and file from the --record options
+ case "set", "output", "require" -> {
+ }
+ case "source" -> {
+ Path file = (dir != null ? dir.resolve(args) :
Path.of(args)).toAbsolutePath().normalize();
+ if (including.contains(file)) {
+ throw new IllegalArgumentException("The tape includes
itself");
+ }
+ List<String> included;
+ try {
+ included = Files.readAllLines(file);
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Cannot read the tape
to include (" + e.getMessage() + ")");
+ }
+ including.push(file);
+ parse(included, file.getParent(), including, steps);
+ including.pop();
+ }
+ case "hide", "show" -> throw new IllegalArgumentException(
+ "Hide and Show are not supported, the interactions are
recorded");
+ case "sleep" -> steps.add(new SleepStep(parseDuration(args)));
+ case "type" -> addTyped(unquote(args), timing >= 0 ? timing :
DEFAULT_TYPING_MILLIS, steps);
+ case "wait", "wait+line", "wait+screen" -> steps.add(new WaitStep(
+ parseRegex(args), "wait+screen".equals(command), timing >
0 ? timing : DEFAULT_WAIT_MILLIS, line));
+ case "screenshot" -> steps.add(new ToolStep("tui_action",
Map.of("action", "screenshot"), line));
+ // the commands for the TUI itself
+ case "caption" -> {
+ Map<String, Object> toolArgs = new LinkedHashMap<>();
+ toolArgs.put("text", unquote(args).replace("\\n", "\n"));
+ if (timing > 0) {
+ toolArgs.put("duration", (int) Math.max(1, timing / 1000));
+ }
+ steps.add(new ToolStep("tui_show_caption", toolArgs, line));
+ }
+ case "highlight" -> steps.add(new HighlightStep(unquote(args),
timing > 0 ? (int) (timing / 1000) : 0, line));
+ case "cleardrawing" -> steps.add(new ToolStep("tui_draw_clear",
Map.of(), line));
+ case "tab" -> steps.add(new ToolStep("tui_navigate", Map.of("tab",
unquote(args)), line));
+ case "integration" -> steps.add(new ToolStep("tui_navigate",
Map.of("integration", unquote(args)), line));
+ case "action" -> steps.add(new ToolStep("tui_action",
Map.of("action", unquote(args)), line));
+ case "theme" -> steps.add(new ToolStep("tui_set_theme",
Map.of("theme", unquote(args)), line));
+ default -> {
+ KeyEvent key = parseKey(head);
+ if (key == null) {
+ throw new IllegalArgumentException("Unknown tape command");
+ }
+ int count = args.isEmpty() ? 1 : parseCount(args);
+ long gap = timing >= 0 ? timing : DEFAULT_TYPING_MILLIS;
+ for (int i = 0; i < count; i++) {
+ steps.add(new KeyStep(key));
+ steps.add(new SleepStep(gap));
+ }
+ }
+ }
+ }
+
+ private static void addTyped(String text, long gap, List<Step> steps) {
+ for (int i = 0; i < text.length();) {
+ int cp = text.codePointAt(i);
+ i += Character.charCount(cp);
+ KeyEvent key = switch (cp) {
+ case '\n', '\r' -> KeyEvent.ofKey(KeyCode.ENTER);
+ case '\t' -> KeyEvent.ofKey(KeyCode.TAB);
+ default -> KeyEvent.ofChar(cp);
+ };
+ steps.add(new KeyStep(key));
+ steps.add(new SleepStep(gap));
+ }
+ }
+
+ /**
+ * A key name with optional modifiers ({@code Enter}, {@code F8}, {@code
Shift+F8}, {@code Ctrl+c}, {@code Alt+x},
+ * {@code Shift+Tab}); null when it is not a key.
+ */
+ static KeyEvent parseKey(String spec) {
+ boolean ctrl = false;
+ boolean alt = false;
+ boolean shift = false;
+ String key = spec;
+ int plus;
+ // a lone "+" is a key of its own, not a modifier separator
+ while ((plus = key.indexOf('+')) > 0 && plus < key.length() - 1) {
+ switch (key.substring(0, plus).toLowerCase(Locale.ROOT)) {
+ case "ctrl", "control" -> ctrl = true;
+ case "alt", "meta" -> alt = true;
+ case "shift" -> shift = true;
+ default -> {
+ return null;
+ }
+ }
+ key = key.substring(plus + 1);
+ }
+ boolean modified = ctrl || alt || shift;
+ KeyModifiers modifiers = KeyModifiers.of(ctrl, alt, shift);
+ KeyCode code = switch (key.toLowerCase(Locale.ROOT)) {
+ case "enter", "return" -> KeyCode.ENTER;
+ case "tab" -> KeyCode.TAB;
+ case "backspace" -> KeyCode.BACKSPACE;
+ case "delete" -> KeyCode.DELETE;
+ case "insert" -> KeyCode.INSERT;
+ case "escape", "esc" -> KeyCode.ESCAPE;
+ case "up" -> KeyCode.UP;
+ case "down" -> KeyCode.DOWN;
+ case "left" -> KeyCode.LEFT;
+ case "right" -> KeyCode.RIGHT;
+ case "home" -> KeyCode.HOME;
+ case "end" -> KeyCode.END;
+ case "pageup", "pgup" -> KeyCode.PAGE_UP;
+ case "pagedown", "pgdn" -> KeyCode.PAGE_DOWN;
+ default -> functionKey(key);
+ };
+ if (code != null) {
+ return KeyEvent.ofKey(code, modifiers);
+ }
+ if ("space".equalsIgnoreCase(key)) {
+ return KeyEvent.ofChar(' ', modifiers);
+ }
+ // a single character is a key only with a modifier (Ctrl+t); a bare
letter on its own line is a typo
+ if (modified && key.codePointCount(0, key.length()) == 1) {
+ int cp = key.codePointAt(0);
+ return KeyEvent.ofChar(shift && Character.isLetter(cp) ?
Character.toUpperCase(cp) : cp, modifiers);
+ }
+ return null;
+ }
+
+ private static KeyCode functionKey(String key) {
+ if (key.length() >= 2 && key.length() <= 3 && (key.charAt(0) == 'F' ||
key.charAt(0) == 'f')) {
+ try {
+ int n = Integer.parseInt(key.substring(1));
+ if (n >= 1 && n <= 12) {
+ return KeyCode.valueOf("F" + n);
+ }
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /** A duration such as {@code 500ms}, {@code 2s} or {@code 1.5s}; a bare
number is seconds, as in VHS. */
+ static long parseDuration(String text) {
+ String s = text.strip().toLowerCase(Locale.ROOT);
+ try {
+ if (s.endsWith("ms")) {
+ return Long.parseLong(s.substring(0, s.length() - 2).strip());
+ }
+ if (s.endsWith("s")) {
+ return Math.round(Double.parseDouble(s.substring(0, s.length()
- 1).strip()) * 1000);
+ }
+ return Math.round(Double.parseDouble(s) * 1000);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid duration '" + text +
"'");
+ }
+ }
+
+ private static int parseCount(String text) {
+ try {
+ return Math.max(1, Integer.parseInt(text.strip()));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid repeat count '" + text
+ "'");
+ }
+ }
+
+ private static Pattern parseRegex(String text) {
+ if (text.length() < 2 || !text.startsWith("/") || !text.endsWith("/"))
{
+ throw new IllegalArgumentException("Wait needs a /regex/ to wait
for");
+ }
+ try {
+ return Pattern.compile(text.substring(1, text.length() - 1));
+ } catch (PatternSyntaxException e) {
+ throw new IllegalArgumentException("Invalid regex (" +
e.getDescription() + ")");
+ }
+ }
+
+ /** The text of a quoted argument ("..." with \" \\ \n \t escapes), or the
argument as it is when unquoted. */
+ static String unquote(String text) {
+ String s = text.strip();
+ if (s.length() < 2 || s.charAt(0) != '"' || s.charAt(s.length() - 1)
!= '"') {
+ return s;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (int i = 1; i < s.length() - 1; i++) {
+ char c = s.charAt(i);
+ if (c == '\\' && i + 1 < s.length() - 1) {
+ char next = s.charAt(++i);
+ switch (next) {
+ case 'n' -> sb.append('\n');
+ case 't' -> sb.append('\t');
+ case 'r' -> sb.append('\r');
+ default -> sb.append(next);
+ }
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ // ---- Playing ----
+
+ /** Plays the steps on the calling thread, then quits the TUI. */
+ void play(List<Step> steps, Driver driver) throws InterruptedException {
+ long start = System.nanoTime();
+ while (driver.screen() == null && System.nanoTime() - start <
TimeUnit.MILLISECONDS.toNanos(FIRST_FRAME_MILLIS)) {
+ Thread.sleep(WAIT_POLL_MILLIS);
+ }
+ for (Step step : steps) {
+ if (step instanceof KeyStep k) {
+ driver.key(k.event());
+ } else if (step instanceof SleepStep s) {
+ Thread.sleep(s.millis());
+ } else if (step instanceof WaitStep w) {
+ waitFor(w, driver);
+ } else if (step instanceof ToolStep t) {
+ callTool(t.name(), t.args(), t.line(), driver);
+ } else if (step instanceof HighlightStep h) {
+ highlight(h, driver);
+ }
+ }
+ Thread.sleep(END_PAUSE_MILLIS);
+ driver.quit();
+ }
+
+ private void waitFor(WaitStep wait, Driver driver) throws
InterruptedException {
+ long deadline = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(wait.timeoutMillis());
+ while (!matches(wait, driver.screen())) {
+ if (System.nanoTime() - deadline >= 0) {
+ warnings.accept("Timed out after " + wait.timeoutMillis() +
"ms: " + wait.line());
+ return;
+ }
+ Thread.sleep(WAIT_POLL_MILLIS);
+ }
+ }
+
+ static boolean matches(WaitStep wait, String screen) {
+ if (screen == null) {
+ return false;
+ }
+ if (wait.wholeScreen()) {
+ return wait.pattern().matcher(screen).find();
+ }
+ for (String row : screen.split("\n", -1)) {
+ if (wait.pattern().matcher(row).find()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String callTool(String name, Map<String, Object> args, String
line, Driver driver) {
+ try {
+ String result = driver.tool(name, args);
+ if (result != null && result.startsWith("Error")) {
+ warnings.accept(result + ": " + line);
+ }
+ return result;
+ } catch (Exception e) {
+ warnings.accept("Failed (" + e.getMessage() + "): " + line);
+ return null;
+ }
+ }
+
+ private void highlight(HighlightStep step, Driver driver) {
+ String located = callTool("tui_locate", Map.of("text", step.text()),
step.line(), driver);
+ JsonObject match = firstMatch(located);
+ if (match == null) {
+ warnings.accept("Text not on the screen: " + step.line());
+ return;
+ }
+ Map<String, Object> args = new LinkedHashMap<>();
+ args.put("shape", "highlight");
+ args.put("x", match.getInteger("x"));
+ args.put("y", match.getInteger("y"));
+ args.put("width", match.getInteger("width"));
+ args.put("append", true);
+ if (step.seconds() > 0) {
+ args.put("duration", step.seconds());
+ }
+ callTool("tui_draw_shape", args, step.line(), driver);
+ }
+
+ private static JsonObject firstMatch(String located) {
+ if (located == null) {
+ return null;
+ }
+ try {
+ Object parsed = Jsoner.deserialize(located);
+ if (parsed instanceof JsonObject result && result.get("matches")
instanceof JsonArray matches
+ && !matches.isEmpty() && matches.get(0) instanceof
JsonObject first) {
+ return first;
+ }
+ } catch (Exception e) {
+ // not a locate result
+ }
+ return null;
+ }
+
+ /** The buffer as text, its rows separated by newlines (continuation cells
of wide characters left out). */
+ static String screenText(Buffer buffer) {
+ if (buffer == null) {
+ return null;
+ }
+ Rect area = buffer.area();
+ StringBuilder sb = new StringBuilder();
+ for (int y = area.top(); y < area.bottom(); y++) {
+ if (y > area.top()) {
+ sb.append('\n');
+ }
+ for (int x = area.left(); x < area.right(); x++) {
+ if (!buffer.get(x, y).isContinuation()) {
+ sb.append(buffer.get(x, y).symbol());
+ }
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
index fdbc5c91e531..a022f5138711 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
@@ -55,7 +55,7 @@ public class TuiCommand extends CamelCommand {
long refreshInterval = 100;
@CommandLine.Option(names = { "--record" },
- description = "Replay a .tape file inside the TUI and
record to an Asciinema .cast file",
+ description = "Play a .tape file in the TUI and record
it to an Asciinema .cast file",
arity = "0..1")
String record;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java
index 9b47a877c040..bd6400565db2 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java
@@ -16,8 +16,13 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.junitpioneer.jupiter.ClearSystemProperty;
import picocli.CommandLine;
@@ -43,6 +48,9 @@ class CamelMonitorRecordOptionsTest {
private final CamelMonitor monitor = new CamelMonitor(new
CamelJBangMain(), getClass().getClassLoader());
+ @TempDir
+ Path tempDir;
+
@Test
void parsesColumnsAndRows() {
assertThat(monitor.parseRecordSize("160x44")).containsExactly(160, 44);
@@ -93,10 +101,10 @@ class CamelMonitorRecordOptionsTest {
}
@Test
- void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards() {
+ void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards()
throws IOException {
// The set and the clear list have to stay in sync: a property added
to configureRecording() but missing
// from RECORD_PROPERTIES would keep recording enabled for the rest of
the JVM's life.
- monitor.record = "demo.tape";
+ monitor.record = tape().toString();
monitor.recordSize = "160x44";
monitor.configureRecording();
@@ -119,4 +127,30 @@ class CamelMonitorRecordOptionsTest {
assertThat(CamelMonitor.RECORD_PROPERTIES)
.allSatisfy(key ->
assertThat(System.getProperty(key)).as(key).isNull());
}
+
+ @Test
+ void doesNotHandTheTapeToTamboui() throws IOException {
+ // TamboUI only records; TapePlayer plays the tape (TamboUI's own
player has no function keys)
+ monitor.record = tape().toString();
+
+ monitor.configureRecording();
+
+ assertThat(System.getProperty("tamboui.record")).endsWith("demo.cast");
+ assertThat(System.getProperty("tamboui.record.config")).isNull();
+ }
+
+ @Test
+ void rejectsAMissingTapeFile() {
+ monitor.record = tempDir.resolve("missing.tape").toString();
+
+ assertThatThrownBy(monitor::configureRecording)
+ .isInstanceOf(CommandLine.ParameterException.class)
+ .hasMessageContaining("Tape file not found");
+ }
+
+ private Path tape() throws IOException {
+ Path tape = tempDir.resolve("demo.tape");
+ Files.writeString(tape, "Sleep 100ms\n");
+ return tape;
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java
new file mode 100644
index 000000000000..f428afd75e09
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java
@@ -0,0 +1,256 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Pattern;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TapePlayerTest {
+
+ private final List<String> warnings = new ArrayList<>();
+ private final TapePlayer player = new TapePlayer(warnings::add);
+
+ @Test
+ void parsesKeysWithModifiers() {
+ assertThat(TapePlayer.parseKey("F8").code()).isEqualTo(KeyCode.F8);
+ KeyEvent shiftF8 = TapePlayer.parseKey("Shift+F8");
+ assertThat(shiftF8.code()).isEqualTo(KeyCode.F8);
+ assertThat(shiftF8.hasShift()).isTrue();
+ KeyEvent ctrlT = TapePlayer.parseKey("Ctrl+t");
+ assertThat(ctrlT.isChar('t')).isTrue();
+ assertThat(ctrlT.hasCtrl()).isTrue();
+ assertThat(TapePlayer.parseKey("Alt+x").hasAlt()).isTrue();
+
assertThat(TapePlayer.parseKey("Shift+Tab").code()).isEqualTo(KeyCode.TAB);
+
assertThat(TapePlayer.parseKey("Escape").code()).isEqualTo(KeyCode.ESCAPE);
+
assertThat(TapePlayer.parseKey("PageDown").code()).isEqualTo(KeyCode.PAGE_DOWN);
+ }
+
+ @Test
+ void doesNotTakeABareLetterOrAnUnknownNameForAKey() {
+ assertThat(TapePlayer.parseKey("q")).isNull();
+ assertThat(TapePlayer.parseKey("F13")).isNull();
+ assertThat(TapePlayer.parseKey("Hyper+x")).isNull();
+ }
+
+ @Test
+ void repeatsAKeyWithACount() {
+ List<TapePlayer.Step> steps = player.parse(List.of("Down 3", "Tab",
"Tab 2"));
+
+ assertThat(keys(steps)).extracting(KeyEvent::code)
+ .containsExactly(KeyCode.DOWN, KeyCode.DOWN, KeyCode.DOWN,
KeyCode.TAB, KeyCode.TAB, KeyCode.TAB);
+ assertThat(warnings).isEmpty();
+ }
+
+ @Test
+ void typesTextWithItsSpeedAndKeepsAtSigns() {
+ List<TapePlayer.Step> steps = player.parse(List.of("Type@100ms
\"a@b\""));
+
+
assertThat(keys(steps)).extracting(KeyEvent::string).containsExactly("a", "@",
"b");
+ assertThat(steps).filteredOn(s -> s instanceof TapePlayer.SleepStep)
+ .extracting(s -> ((TapePlayer.SleepStep) s).millis())
+ .containsOnly(100L);
+ }
+
+ @Test
+ void parsesDurationsAsInVhs() {
+ assertThat(TapePlayer.parseDuration("500ms")).isEqualTo(500);
+ assertThat(TapePlayer.parseDuration("2s")).isEqualTo(2000);
+ assertThat(TapePlayer.parseDuration("1.5s")).isEqualTo(1500);
+ assertThat(TapePlayer.parseDuration("3")).isEqualTo(3000);
+ }
+
+ @Test
+ void parsesWaitWithItsTimeout() {
+ TapePlayer.WaitStep wait = (TapePlayer.WaitStep)
player.parse(List.of("Wait+Screen@2s /Ready/")).get(0);
+
+ assertThat(wait.pattern().pattern()).isEqualTo("Ready");
+ assertThat(wait.wholeScreen()).isTrue();
+ assertThat(wait.timeoutMillis()).isEqualTo(2000);
+ }
+
+ @Test
+ void parsesTheTuiCommands() {
+ List<TapePlayer.Step> steps = player.parse(List.of(
+ "Caption@5s \"Fix with AI\\nShift+F8\"",
+ "Tab \"Errors\"",
+ "Integration orders",
+ "Action \"show-keystrokes\"",
+ "Theme \"dracula\"",
+ "ClearDrawing"));
+
+ assertThat(steps).hasSize(6).allMatch(s -> s instanceof
TapePlayer.ToolStep);
+ TapePlayer.ToolStep caption = (TapePlayer.ToolStep) steps.get(0);
+ assertThat(caption.name()).isEqualTo("tui_show_caption");
+ assertThat(caption.args()).containsEntry("text", "Fix with
AI\nShift+F8").containsEntry("duration", 5);
+ assertThat(((TapePlayer.ToolStep)
steps.get(1)).args()).containsEntry("tab", "Errors");
+ assertThat(((TapePlayer.ToolStep)
steps.get(2)).args()).containsEntry("integration", "orders");
+ assertThat(((TapePlayer.ToolStep)
steps.get(3)).name()).isEqualTo("tui_action");
+ assertThat(((TapePlayer.ToolStep)
steps.get(4)).name()).isEqualTo("tui_set_theme");
+ assertThat(((TapePlayer.ToolStep)
steps.get(5)).name()).isEqualTo("tui_draw_clear");
+ assertThat(warnings).isEmpty();
+ }
+
+ @Test
+ void reportsAndSkipsWhatItDoesNotKnow() {
+ List<TapePlayer.Step> steps = player.parse(List.of(
+ "Set FontSize 32", "Output demo.gif", "Frobnicate", "q",
"Hide", "Wait /[unclosed/", "Enter"));
+
+
assertThat(keys(steps)).extracting(KeyEvent::code).containsExactly(KeyCode.ENTER);
+ assertThat(warnings).hasSize(4);
+ assertThat(warnings.get(0)).contains("Unknown tape command:
Frobnicate");
+ assertThat(warnings.get(1)).contains("Unknown tape command: q");
+ assertThat(warnings.get(2)).contains("Hide and Show are not
supported");
+ assertThat(warnings.get(3)).contains("Invalid regex");
+ }
+
+ @Test
+ void includesAnotherTapeAndReportsATapeThatIncludesItself(@TempDir Path
dir) throws IOException {
+ Files.writeString(dir.resolve("main.tape"), "Source
common.tape\nSource common.tape\nSource loop.tape\nEnter\n");
+ Files.writeString(dir.resolve("common.tape"), "Escape\n");
+ Files.writeString(dir.resolve("loop.tape"), "Tab\nSource ../" +
dir.getFileName() + "/main.tape\n");
+
+ List<TapePlayer.Step> steps = player.parse(dir.resolve("main.tape"));
+
+ assertThat(keys(steps)).extracting(KeyEvent::code)
+ .containsExactly(KeyCode.ESCAPE, KeyCode.ESCAPE, KeyCode.TAB,
KeyCode.ENTER);
+ assertThat(warnings).singleElement().asString().startsWith("The tape
includes itself");
+ }
+
+ @Test
+ void waitMatchesALineOrTheWholeScreen() {
+ String screen = "Loading\nReady ";
+ TapePlayer.WaitStep line = new
TapePlayer.WaitStep(Pattern.compile("Loading\\s*\\nReady"), false, 0, "");
+ TapePlayer.WaitStep whole = new
TapePlayer.WaitStep(Pattern.compile("Loading\\s*\\nReady"), true, 0, "");
+
+ assertThat(TapePlayer.matches(line, screen)).isFalse();
+ assertThat(TapePlayer.matches(whole, screen)).isTrue();
+ assertThat(TapePlayer.matches(whole, null)).isFalse();
+ }
+
+ @Test
+ void playsKeysWaitsForTheScreenHighlightsAndQuits() throws Exception {
+ AtomicReference<String> screen = new AtomicReference<>("Loading");
+ List<KeyEvent> pressed = new ArrayList<>();
+ List<String> tools = new ArrayList<>();
+ AtomicBoolean quit = new AtomicBoolean();
+ TapePlayer.Driver driver = new TapePlayer.Driver() {
+ @Override
+ public void key(KeyEvent event) {
+ pressed.add(event);
+ if (event.code() == KeyCode.F5) {
+ screen.set("Errors\n Shift+F8 fix with AI");
+ }
+ }
+
+ @Override
+ public String screen() {
+ return screen.get();
+ }
+
+ @Override
+ public String tool(String name, Map<String, Object> args) {
+ tools.add(name + " " + args);
+ if ("tui_locate".equals(name)) {
+ return
"{\"matches\":[{\"x\":1,\"y\":1,\"width\":8,\"height\":1,\"text\":\"Shift+F8\"}]}";
+ }
+ return "ok";
+ }
+
+ @Override
+ public void quit() {
+ quit.set(true);
+ }
+ };
+
+ player.play(player.parse(List.of(
+ "F5",
+ "Wait@2s /fix with AI/",
+ "Highlight \"Shift+F8\"",
+ "Shift+F8")), driver);
+
+
assertThat(pressed).extracting(KeyEvent::code).containsExactly(KeyCode.F5,
KeyCode.F8);
+ assertThat(pressed.get(1).hasShift()).isTrue();
+ assertThat(tools).hasSize(2);
+ assertThat(tools.get(0)).startsWith("tui_locate");
+
assertThat(tools.get(1)).startsWith("tui_draw_shape").contains("x=1").contains("y=1").contains("width=8");
+ assertThat(quit).isTrue();
+ assertThat(warnings).isEmpty();
+ }
+
+ @Test
+ void reportsAWaitThatTimesOut() throws Exception {
+ TapePlayer.Driver driver = new TapePlayer.Driver() {
+ @Override
+ public void key(KeyEvent event) {
+ }
+
+ @Override
+ public String screen() {
+ return "Loading";
+ }
+
+ @Override
+ public String tool(String name, Map<String, Object> args) {
+ return "ok";
+ }
+
+ @Override
+ public void quit() {
+ }
+ };
+
+ player.play(player.parse(List.of("Wait@200ms /Ready/")), driver);
+
+ assertThat(warnings).singleElement().asString().startsWith("Timed out
after 200ms");
+ }
+
+ @Test
+ void screenTextLeavesOutTheContinuationOfWideCharacters() {
+ Buffer buffer = Buffer.empty(new Rect(0, 0, 4, 2));
+ buffer.setString(0, 0, "世a", Style.EMPTY);
+ buffer.setString(0, 1, "ok", Style.EMPTY);
+
+ assertThat(TapePlayer.screenText(buffer)).isEqualTo("世a \nok ");
+ }
+
+ private static List<KeyEvent> keys(List<TapePlayer.Step> steps) {
+ List<KeyEvent> keys = new ArrayList<>();
+ for (TapePlayer.Step step : steps) {
+ if (step instanceof TapePlayer.KeyStep k) {
+ keys.add(k.event());
+ }
+ }
+ return keys;
+ }
+}