davsclaus commented on code in PR #27663:
URL: https://github.com/apache/camel/pull/27663#discussion_r4237833840


##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java:
##########
@@ -0,0 +1,462 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+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<>();
+        parse(Files.readAllLines(tape), tape.toAbsolutePath().getParent(), 
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, steps);
+        return steps;
+    }
+
+    private void parse(List<String> lines, Path dir, List<Step> steps) {
+        for (String raw : lines) {
+            String line = raw.strip();
+            if (line.isEmpty() || line.startsWith("#")) {
+                continue;
+            }
+            try {
+                parseLine(line, dir, steps);
+            } catch (IllegalArgumentException e) {
+                warnings.accept(e.getMessage() + ": " + line);
+            }
+        }
+    }
+
+    private void parseLine(String line, Path dir, 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);
+
+        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);
+                try {
+                    parse(Files.readAllLines(file), 
file.toAbsolutePath().getParent(), steps);
+                } catch (IOException e) {
+                    throw new IllegalArgumentException("Cannot read the tape 
to include (" + e.getMessage() + ")");
+                }

Review Comment:
   Fixed in b95ebab4ea04: the tapes being parsed are kept on a stack, and a 
`Source` of a tape already on it is reported as `The tape includes itself` and 
skipped (the same tape can still be included twice in a row). Covered by 
`includesAnotherTapeAndReportsATapeThatIncludesItself`, which also caught that 
a bare `Tab` line was taken as the tab command instead of the Tab key, fixed in 
the same commit.
   
   _Claude Code on behalf of davsclaus_



##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java:
##########
@@ -0,0 +1,462 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+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<>();
+        parse(Files.readAllLines(tape), tape.toAbsolutePath().getParent(), 
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, steps);
+        return steps;
+    }
+
+    private void parse(List<String> lines, Path dir, List<Step> steps) {
+        for (String raw : lines) {
+            String line = raw.strip();
+            if (line.isEmpty() || line.startsWith("#")) {
+                continue;
+            }
+            try {
+                parseLine(line, dir, steps);
+            } catch (IllegalArgumentException e) {
+                warnings.accept(e.getMessage() + ": " + line);
+            }
+        }
+    }
+
+    private void parseLine(String line, Path dir, 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);
+
+        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);
+                try {
+                    parse(Files.readAllLines(file), 
file.toAbsolutePath().getParent(), steps);
+                } catch (IOException e) {
+                    throw new IllegalArgumentException("Cannot read the tape 
to include (" + e.getMessage() + ")");
+                }
+            }
+            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.currentTimeMillis();
+        while (driver.screen() == null && System.currentTimeMillis() - start < 
FIRST_FRAME_MILLIS) {

Review Comment:
   Fixed in b95ebab4ea04: the first-frame wait and the `Wait` deadline now use 
`System.nanoTime()`.
   
   _Claude Code on behalf of davsclaus_



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to